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:
cliffhall 2026-09-06 10:11:09 -04:00
commit 6711593a37

View file

@ -17,6 +17,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ANALYSIS_MARKER,
assertEveryPackageWatched,
buildIssueBody,
buildIssueTitle,
@ -24,6 +25,7 @@ import {
buildSupersededComment,
formatFiledOutput,
groupState,
hasAnalysis,
installedVersion,
main,
parseMarker,
@ -93,6 +95,33 @@ test("parseSupersededMarker reads back the issue number it announced", () => {
assert.equal(parseSupersededMarker("unrelated comment"), null);
});
test("hasAnalysis finds the marker only at the start of a comment", () => {
assert.equal(hasAnalysis([`${ANALYSIS_MARKER}\nthe write-up`]), true);
assert.equal(hasAnalysis(["a maintainer comment", "another"]), false);
assert.equal(hasAnalysis([]), false);
// A comment merely QUOTING the marker must not count as an analysis, or one
// person pasting it would suppress the retry forever.
assert.equal(
hasAnalysis([`see \`${ANALYSIS_MARKER}\` in the script`]),
false,
);
});
test("the workflow posts the exact marker the sweep looks for", () => {
// ⚠️ The marker is duplicated across the script and the workflow because the
// posting step is shell, not JS. If the two ever drift, every issue reads as
// permanently unanalyzed and the sweep re-queues it every single night — a
// failure that is invisible in both files read separately. This is the guard.
const workflow = readFileSync(
new URL("../.github/workflows/sdk-watch.yml", import.meta.url),
"utf8",
);
assert.ok(
workflow.includes(ANALYSIS_MARKER),
`.github/workflows/sdk-watch.yml must post ${ANALYSIS_MARKER}`,
);
});
// ---------------------------------------------------------------------------
// The unwatched-package guard
// ---------------------------------------------------------------------------
@ -378,6 +407,7 @@ function fakeSpawn({
{ title: "v2.6.0", state: "open", due_on: "2026-09-09T00:00:00Z" },
],
comments = [],
commentsByIssue = {},
createStatus = 0,
createFailFor = null,
commentStatus = 0,
@ -418,8 +448,18 @@ function fakeSpawn({
// MUST be tested before the milestone branch: both are `gh api`, so
// matching on args[0] alone would hand the comment lookup the milestone
// payload and the assertion would silently check nothing.
if (args[0] === "api" && args.some((a) => String(a).includes("/comments")))
return { status: 0, stdout: comments.join("\n"), stderr: "" };
if (
args[0] === "api" &&
args.some((a) => String(a).includes("/comments"))
) {
// Answer PER ISSUE. A single shared list would make "the target issue has
// an analysis" and "the stale issue has a supersession note" the same
// fact, so a test could pass on the wrong one entirely.
const path = args.find((a) => String(a).includes("/comments")) ?? "";
const number = Number(/issues\/(\d+)\/comments/.exec(path)?.[1]);
const bodies = commentsByIssue[number] ?? comments;
return { status: 0, stdout: bodies.join("\n"), stderr: "" };
}
if (args[0] === "api")
return { status: 0, stdout: JSON.stringify(milestones), stderr: "" };
throw new Error(`unexpected call: ${cmd} ${args.join(" ")}`);
@ -552,16 +592,20 @@ test("main files one issue per upstream when both groups are behind", () => {
assert.equal(filed[1].from, "1.7.5", "from is the installed version");
});
test("main does not refile when an issue already covers this target", () => {
/** An open issue this sweep already filed for `target`. */
function existingIssue(number, target, group = SDK) {
return {
number,
state: "OPEN",
body: `${buildMarker(group, target)}\nexisting`,
};
}
test("main does not refile an issue that already exists and was analyzed", () => {
const spawn = fakeSpawn({
latest: latestAt(SDK, "2.1.0"),
issues: [
{
number: 400,
state: "OPEN",
body: `${buildMarker(SDK, "2.1.0")}\nexisting`,
},
],
issues: [existingIssue(400, "2.1.0")],
commentsByIssue: { 400: [`${ANALYSIS_MARKER}\nthe analysis`] },
});
const output = outputFile();
writeFileSync(output, "");
@ -575,7 +619,70 @@ test("main does not refile when an issue already covers this target", () => {
assert.deepEqual(
readFiled(output),
[],
"an already-filed target must not reach the analysis job again",
"an already-analyzed target must not reach the analysis job again",
);
});
test("main re-queues an existing issue that has no analysis comment", () => {
// ⚠️ "an issue exists" and "the issue was analyzed" are different claims, and
// equating them meant a failed or timed-out `analyze` job was never retried:
// the next sweep saw the marker, emitted `[]`, and reported a green no-op over
// an issue nothing would ever revisit (Copilot).
const spawn = fakeSpawn({
latest: latestAt(SDK, "2.1.0"),
issues: [existingIssue(400, "2.1.0")],
commentsByIssue: { 400: ["just a maintainer chiming in"] },
});
const output = outputFile();
writeFileSync(output, "");
main("o/r", spawn, { readFile: fakeReadFile(), output });
assert.equal(
spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "create"),
false,
"re-queuing must adopt the existing issue, never file a second one",
);
assert.deepEqual(
readFiled(output).map((f) => f.issue),
[400],
"the un-analyzed issue must reach the analysis job again",
);
});
test("main retries a supersession note that failed to post on an earlier run", () => {
// The unrecoverable case: creation succeeded, the note did not, and the retry
// matched the target's own marker and skipped reconciliation entirely — so the
// documented note was never posted at all (Copilot).
const spawn = fakeSpawn({
latest: latestAt(SDK, "2.2.0"),
issues: [existingIssue(500, "2.2.0"), existingIssue(400, "2.1.0")],
commentsByIssue: { 500: [`${ANALYSIS_MARKER}\ndone`], 400: [] },
});
const output = outputFile();
writeFileSync(output, "");
main("o/r", spawn, { readFile: fakeReadFile(), output });
const posted = spawn.calls.find(
(c) => c.args[0] === "issue" && c.args[1] === "comment",
);
assert.ok(posted, "the missing supersession note must be posted on retry");
assert.equal(posted.args[2], "400");
assert.ok(posted.args[posted.args.indexOf("--body") + 1].includes("#500"));
});
test("main does not treat the target issue as superseding itself", () => {
const spawn = fakeSpawn({
latest: latestAt(SDK, "2.1.0"),
issues: [existingIssue(400, "2.1.0")],
commentsByIssue: { 400: [`${ANALYSIS_MARKER}\ndone`] },
});
main("o/r", spawn, noAmbientOutput());
assert.equal(
spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "comment"),
false,
);
});