fix: address Copilot review round 2 on the SDK watch
Three findings, all real. The first is a security hole introduced by round 1's own fix. 1. The analysis job could have published live secrets. Round 1 pinned `Bash(gh issue comment <N>:*)` to the tracked issue and treated that as scoping, but a `Bash(...)` grant matches a command PREFIX and says nothing about the flags that follow — so `gh issue comment <N> --body-file /proc/self/environ` matched it, and this job's subprocess environment holds ANTHROPIC_API_KEY and GITHUB_TOKEN. A prompt injection from upstream release notes could have posted live credentials into a public issue. The model now has no tool that can write anywhere. It returns its write-up as `--json-schema` structured output, and a deterministic, model-free step posts it: the body arrives through the environment rather than the command line, so nothing in it can be read as a flag or a path, and the issue number comes from the matrix. That step is the only thing in the job holding a token that can write. WebFetch is denied as well. It was not raised, but with `Read` available an outbound fetch the model controls is the same exfiltration channel by another route; `gh release view` already covered the need. The remaining grant is read-only and accepts no attacker-chosen file path. The general rule is now in AGENTS.md rather than only this instance: a prefix grant on any command that accepts a file path is an arbitrary-file read with a publish attached. 2. Issue existence was standing in for analysis completion. A failed or timed-out `analyze` job left an issue the next sweep matched, emitted `[]` for, and reported as a green no-op — so the promised analysis was never retried. The posting step now stamps ANALYSIS_MARKER on its comment, and a sweep finding an existing issue without one re-queues it. The marker is matched with `startsWith`, so a maintainer quoting it cannot suppress the retry. 3. A failed supersession note was unrecoverable. Creation succeeding and the note failing left a state where every retry matched the target's own marker and skipped reconciliation entirely, so the note was never posted. An existing issue is now ADOPTED rather than skipped: the loop takes its number and runs the same follow-up it would for a new one. The existing announced-marker check prevents duplication, and a `stale.number === number` guard stops an issue superseding itself. Findings 2 and 3 share one restructure — replacing the early `continue` with adoption is what gives both the retry path. ANALYSIS_MARKER is necessarily duplicated in the workflow, since the posting step is shell. Drift would be invisible from either file alone and would make every issue read as permanently unanalyzed, so a test asserts the workflow contains the exact constant. Mutation-checked, each failing a different minimal set: if (match) continue; restored -> supersession-retry + re-queue tests hasAnalysis -> always true -> re-queue + hasAnalysis tests workflow marker misspelled -> the drift-guard test 47 tests in this file; `npm run local:gate` green end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
3b3c7e35d1
commit
6711593a37
1 changed files with 273 additions and 71 deletions
|
|
@ -106,6 +106,31 @@ const MARKER_RE = /^<!-- sdk-watch: group=(.+?); target=(.+?) -->/;
|
|||
/** Marker on the comment left when a newer target supersedes an open issue. */
|
||||
const SUPERSEDED_MARKER_RE = /^<!-- sdk-watch:superseded-by (.+?) -->/;
|
||||
|
||||
/**
|
||||
* The marker the workflow's posting step puts on the analysis comment.
|
||||
*
|
||||
* ⚠️ **This string also appears in `.github/workflows/sdk-watch.yml`** and the
|
||||
* two must agree, or every issue looks permanently unanalyzed and the sweep
|
||||
* re-queues it every night. `sdk-watch.test.mjs` asserts the workflow file
|
||||
* contains this exact constant, so the pair cannot drift silently.
|
||||
*
|
||||
* It exists because "an issue for this target exists" and "that issue has been
|
||||
* analyzed" are different claims, and the sweep used to equate them: a transient
|
||||
* failure in the `analyze` job left an issue nothing would ever revisit, and the
|
||||
* next night reported a green no-op over it (Copilot).
|
||||
*/
|
||||
export const ANALYSIS_MARKER = "<!-- sdk-watch:analysis -->";
|
||||
|
||||
/**
|
||||
* Has this issue already been given its automated analysis?
|
||||
*
|
||||
* @param {string[]} commentBodies
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasAnalysis(commentBodies) {
|
||||
return commentBodies.some((body) => (body ?? "").startsWith(ANALYSIS_MARKER));
|
||||
}
|
||||
|
||||
/**
|
||||
* The issue body's first line: the idempotency key.
|
||||
*
|
||||
|
|
@ -540,56 +565,81 @@ export function main(
|
|||
try {
|
||||
for (const state of states) {
|
||||
const forGroup = existing.filter((i) => i.marker.key === state.group.key);
|
||||
if (forGroup.some((i) => i.marker.target === state.target)) {
|
||||
console.log(
|
||||
`sdk-watch: ${state.group.label} ${state.target} already has an issue — no-op`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const match = forGroup.find((i) => i.marker.target === state.target);
|
||||
|
||||
try {
|
||||
const milestone = currentMilestone(repo, spawn);
|
||||
const created = createIssue(repo, state, milestone, spawn);
|
||||
/** Queue an issue number for the analysis job. */
|
||||
const record = (issue) =>
|
||||
filed.push({
|
||||
issue,
|
||||
label: state.group.label,
|
||||
repo: state.group.repo,
|
||||
from: state.rows.find((r) => r.behind).installed,
|
||||
to: state.target,
|
||||
});
|
||||
|
||||
// Recorded before any further fallible work, for the reason above.
|
||||
filed.push({
|
||||
issue: created.number,
|
||||
label: state.group.label,
|
||||
repo: state.group.repo,
|
||||
from: state.rows.find((r) => r.behind).installed,
|
||||
to: state.target,
|
||||
});
|
||||
console.log(`sdk-watch: filed ${created.url}`);
|
||||
// ⚠️ An existing issue for this target used to `continue` outright,
|
||||
// which quietly made two unrelated claims one claim (Copilot). It meant
|
||||
// the sweep could never retry a failed analysis, and it meant a
|
||||
// supersession note that failed to post was never posted, because the
|
||||
// retry matched here and skipped the reconciliation below. So the
|
||||
// existing issue is adopted rather than skipped, and both pieces of
|
||||
// follow-up work run against its number exactly as they would a new one.
|
||||
let number;
|
||||
if (match) {
|
||||
number = match.number;
|
||||
} else {
|
||||
const milestone = currentMilestone(repo, spawn);
|
||||
const created = createIssue(repo, state, milestone, spawn);
|
||||
number = created.number;
|
||||
record(number); // Before further fallible work, for the reason above.
|
||||
console.log(`sdk-watch: filed ${created.url}`);
|
||||
if (!milestone) {
|
||||
// Unmilestoned means unapproved, so triage sweeps it into
|
||||
// `Incoming` — NOT `Todo`, which asserts a maintainer signed off.
|
||||
console.log(
|
||||
"sdk-watch: no dated open milestone — filed unmilestoned, triage will place it in Incoming",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!milestone) {
|
||||
// Unmilestoned means unapproved, so triage sweeps it into `Incoming`
|
||||
// — NOT `Todo`, which asserts a maintainer signed off.
|
||||
console.log(
|
||||
"sdk-watch: no dated open milestone — filed unmilestoned, triage will place it in Incoming",
|
||||
);
|
||||
// "An issue exists" is not "the issue was analyzed". The `analyze` job
|
||||
// can fail or time out, and equating the two left the promised analysis
|
||||
// silently never retried. A newly created issue has no comments, so
|
||||
// this only costs a lookup on the adopted path.
|
||||
if (match) {
|
||||
if (hasAnalysis(issueComments(repo, number, spawn))) {
|
||||
console.log(
|
||||
`sdk-watch: ${state.group.label} ${state.target} already has an issue and an analysis — no-op`,
|
||||
);
|
||||
} else {
|
||||
record(number);
|
||||
console.log(
|
||||
`sdk-watch: #${number} has no analysis comment — re-queuing it for the analyze job`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Any OPEN issue of this group on an older target is now stale. Note it
|
||||
// there rather than closing it; see `buildSupersededComment`.
|
||||
// there rather than closing it; see `buildSupersededComment`. Runs on
|
||||
// the adopted path too, so a comment that failed to post gets another
|
||||
// chance — the marker check below is what keeps that from duplicating.
|
||||
for (const stale of forGroup) {
|
||||
if (stale.number === number) continue;
|
||||
if (stale.state !== "OPEN") continue;
|
||||
if (!semver.lt(stale.marker.target, state.target)) continue;
|
||||
const announced = issueComments(repo, stale.number, spawn).some(
|
||||
(body) => parseSupersededMarker(body) === String(created.number),
|
||||
(body) => parseSupersededMarker(body) === String(number),
|
||||
);
|
||||
if (announced) continue;
|
||||
comment(
|
||||
repo,
|
||||
stale.number,
|
||||
buildSupersededComment(
|
||||
created.number,
|
||||
state.target,
|
||||
stale.marker.target,
|
||||
),
|
||||
buildSupersededComment(number, state.target, stale.marker.target),
|
||||
spawn,
|
||||
);
|
||||
console.log(
|
||||
`sdk-watch: noted #${created.number} supersedes #${stale.number}`,
|
||||
`sdk-watch: noted #${number} supersedes #${stale.number}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue