Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 71 additions & 10 deletions packages/cli/src/commands/review/agent-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { execFileSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
utimesSync,
writeFileSync,
Expand Down Expand Up @@ -1479,7 +1480,7 @@ describe('--roster — every prompt the plan requires, in one call', () => {
// is launched, which makes this the one place the pipeline can notice that
// the tree those agents are about to read is not the commit they think it
// is. A real git worktree, because `git status` is the oracle.
const dir = mkdtempSync(join(tmpdir(), 'ap-residue-'));
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'ap-residue-')));
// Ambient host git config (a global `commit.gpgsign` with no key, a
// `core.hooksPath` that fails) makes the fixture commit throw and reddens
// this test for reasons the branch never touched — the incident
Expand All @@ -1504,15 +1505,18 @@ describe('--roster — every prompt the plan requires, in one call', () => {
writeFileSync(join(wt, '__probe__.test.ts'), 'it("x", () => {});');

const plan = join(dir, 'plan.json');
writeFileSync(
plan,
JSON.stringify({
...PLAN,
worktreePath: wt,
prNumber: '9207',
ownerRepo: 'QwenLM/qwen-code',
}),
);
const writePlan = (fields: Record<string, unknown>) =>
writeFileSync(
plan,
JSON.stringify({
...PLAN,
worktreePath: wt,
prNumber: '9207',
ownerRepo: 'QwenLM/qwen-code',
...fields,
}),
);
writePlan({ fetchedSha: git('rev-parse', 'HEAD').trim() });
(agentPromptCommand.handler as (a: unknown) => void)({
plan,
roster: true,
Expand All @@ -1533,6 +1537,37 @@ describe('--roster — every prompt the plan requires, in one call', () => {
expect(readFileSync(briefPath(plan, '1b'), 'utf8')).toContain(
'And right now it is not clean',
);

// The handover is the wiring under test: drop it and the brief degrades
// in one of two ways, both refused — a WRONG sha (the forge's own)
// reaches the pin and is refused there, a MISSING one fails closed
// before the probe runs, because every worktree-mode fetch writes the
// field and its absence means the plan was tampered with. Either way
// the brief carries the unmeasured sentence, never a clean verdict.
const briefOf = (fields: Record<string, unknown>) => {
writePlan(fields);
(agentPromptCommand.handler as (a: unknown) => void)({
plan,
roster: true,
});
return readFileSync(briefPath(plan, '1a'), 'utf8');
};
const wrongSha = briefOf({ fetchedSha: `deadbeef${'0'.repeat(32)}` });
expect(wrongSha).toContain('Whether it is clean could not be measured');
expect(wrongSha).toContain('not the fetched PR head');
// The framing names a reason, not a failed `git status` — the status
// never ran for these refusals, and a triager sent to debug the git
// environment would find nothing to fix.
expect(wrongSha).toContain('(reason: ');
expect(wrongSha).not.toContain('(`git status` failed');
const noSha = briefOf({});
expect(noSha).toContain('Whether it is clean could not be measured');
expect(noSha).toContain('no usable record of the fetched head sha');
// The stderr warning the handler prints for the same state carries the
// same neutral framing.
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('(reason: '),
);
} finally {
rmSync(dir, { recursive: true, force: true });
gitIsolation.dispose();
Expand Down Expand Up @@ -2839,6 +2874,32 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
expect(
buildRoleBrief(PR_PLAN, 'verify', { key: 'verify; rm -rf /' }),
).toContain('--label verify__rm_-rf__');
// The plan's fetched sha rides along when the plan carries a usable one:
// it is the shared-tree residue check's identity anchor, and without it
// the check would refuse every healthy run (#9742). Absent or malformed,
// nothing is welded — the record-less refusal is the fail-closed shape.
const sha = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
// Pin the JOINED fragment, not the bare flag: without the continuation
// after `--label` the snippet is two statements — the command runs
// unpinned and the sha line dies as command-not-found — while
// `toContain('--fetched-sha …')` still passes.
expect(
buildRoleBrief({ ...PR_PLAN, fetchedSha: sha }, 'verify', {
key: 'verify--round-2--deadbeef1234',
}),
).toContain(
`--label verify--round-2--deadbeef1234 \\
--fetched-sha ${sha}`,
);
// And the sha-less brief must not carry a continuation after the label
// either — a dangling one would glue the closing fence onto the command.
expect(p).not.toMatch(/--label verify--round-2--deadbeef1234 \\/);
expect(p).not.toContain('--fetched-sha');
expect(
buildRoleBrief({ ...PR_PLAN, fetchedSha: 'not-a-sha' }, 'verify', {
key: 'verify--round-2--deadbeef1234',
}),
).not.toContain('--fetched-sha');
// No worktree, no scratch tree — a local or cross-repo review has no
// pristine sibling to build, and HEAD is not what is under review there.
expect(buildRoleBrief(PLAN, 'verify')).not.toContain('review scratch-tree');
Expand Down
56 changes: 51 additions & 5 deletions packages/cli/src/commands/review/agent-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ import { SHA_RE } from './lib/ledger.js';
import { pathRulesFor } from './lib/path-rules.js';
import { shellQuotePath } from './lib/shell-quote.js';
import { inertPath, scratchLabel } from './lib/paths.js';
import { worktreeResidue, type WorktreeResidue } from './lib/worktree.js';
import {
RESIDUE_PATH_CAP,
worktreeResidue,
type WorktreeResidue,
} from './lib/worktree.js';
import {
isTerritoryFanOut,
requiredAgents,
Expand Down Expand Up @@ -151,6 +155,8 @@ interface PlanReport {
prNumber?: unknown;
ownerRepo?: unknown;
worktreePath?: unknown;
/** The PR head sha fetch-pr recorded — the probe's identity anchor. */
fetchedSha?: unknown;
mergeBaseSha?: unknown;
host?: unknown;
repositoryContext?: unknown;
Expand Down Expand Up @@ -1316,6 +1322,19 @@ function repositoryContextBlock(context: RepositoryContext): string[] {
];
}

/**
* The plan's fetched head sha when it carries a usable one. Absent or
* malformed answers nothing rather than a broken anchor: every worktree-mode
* fetch writes the field, so both call sites fail closed on that absence,
* each in its own way.
*/
function fetchedShaOf(report: PlanReport): string | undefined {
const sha = report.fetchedSha;
return typeof sha === 'string' && /^[0-9a-f]{40}$/i.test(sha)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R9-3: This validator rejects valid 64-character Git SHA-256 object IDs. In a SHA-256 repository, fetch-pr records a 64-hex commit, but fetchedShaOf() returns undefined; worktree prompts then fail closed as though the trusted record were missing, and the generated scratch-tree command omits --fetched-sha. A focused probe returned false for a valid 64-hex ID and true after accepting both full Git object-ID lengths. Please use the shared full-object-ID predicate (40 or 64 hex) and add a SHA-256 plan test proving the value reaches worktreeResidue and the scratch-tree command.

中文说明

[严重] R9-3:这里会拒绝合法的 64 字符 Git SHA-256 对象 ID。在 SHA-256 仓库中,fetch-pr 会记录 64 位十六进制提交,但 fetchedShaOf() 返回 undefined;工作树 prompt 随后会像可信记录缺失一样失败关闭,生成的 scratch-tree 命令也会漏掉 --fetched-sha。定向探针显示合法 64 位 ID 当前返回 false,接受两种完整 Git 对象 ID 长度后返回 true。请使用共享的完整对象 ID 判定(40 或 64 位十六进制),并添加 SHA-256 plan 测试,证明该值会到达 worktreeResidue 和 scratch-tree 命令。

— gpt-5.4 via Qwen Code /review (v0.22.0)

? sha
: undefined;
}

/**
* The review worktree's residue, or nothing at all when there is no worktree to
* have any. Resolved against the process cwd, like every other use of
Expand All @@ -1325,7 +1344,28 @@ function repositoryContextBlock(context: RepositoryContext): string[] {
function worktreeResidueOf(report: PlanReport): WorktreeResidue {
const wt = report.worktreePath;
if (typeof wt !== 'string' || !wt) return { paths: [], total: 0 };
return worktreeResidue(resolve(wt));
// Hand over the sha fetch-pr recorded: committing the contamination moves
// a forge's HEAD off it, so with it the probe refuses a forged admin entry
// (see worktreeResidue). The record raises the plant's cost; it does not
// make planting impossible — it is re-read from the plan file at every
// invocation, and a same-user writer can rewrite it along with the forge.
// Absent or malformed it fails CLOSED: every worktree-mode fetch writes
// the field, so a plan that names a worktree without it is tampered or
// corrupted, and measuring unpinned would certify whichever index the
// gitfile names.
const sha = fetchedShaOf(report);
if (sha === undefined) {
return {
paths: [],
total: 0,
unmeasured:
'the plan carries no usable record of the fetched head sha — ' +
'every worktree-mode fetch writes one, so its absence means ' +
'tampering or corruption, and measuring without it would certify ' +
'whichever index the .git gitfile names',
};
}
return worktreeResidue(resolve(wt), RESIDUE_PATH_CAP, sha);
}

/**
Expand Down Expand Up @@ -1380,7 +1420,7 @@ function worktreeEvidenceBlock(
if (residue?.unmeasured) {
parts.push(
'',
`**Whether it is clean could not be measured** (\`git status\` failed: ` +
`**Whether it is clean could not be measured** (reason: ` +
`${inertPath(residue.unmeasured)}). That is not the same as clean: treat ` +
'anything that surprises you in this tree as unverified until you have ' +
'checked it against `git show HEAD:<path>`.',
Expand Down Expand Up @@ -1649,6 +1689,11 @@ export function buildRoleBrief(
// written into a shell command, and the one function that decides the
// tree's name is also what keeps a metacharacter out of that command.
const label = scratchLabel(opts.key ?? role);
// The identity anchor fetch-pr recorded, when the plan carries a usable
// one: with it the probe pins the shared tree and a healthy run measures
// clean — without it the no-record refusal fires on every run, and a
// tampering note that fires always is a note nobody reads.
const sha = fetchedShaOf(report);
parts.push(
'',
'**Your scratch tree — where every probe, mutant and candidate fix goes.** ' +
Expand All @@ -1667,7 +1712,8 @@ export function buildRoleBrief(
// a bare interpolation, and the failure would be silent — every shard's
// scratch tree unavailable, every probe demoted to a reading.
`"\${QWEN_CODE_CLI:-qwen}" review scratch-tree --worktree ${shellQuotePath(resolve(wt))} \\`,
` --label ${label}`,
` --label ${label}${sha === undefined ? '' : ' \\'}`,
...(sha === undefined ? [] : [` --fetched-sha ${sha}`]),
'```',
'',
'It reports `path` — work there, and leave what you leave: `cleanup` sweeps ' +
Expand Down Expand Up @@ -3212,7 +3258,7 @@ function runAgentPrompt(args: AgentPromptArgs): void {
const residue = worktreeResidueOf(report);
if (residue.unmeasured) {
writeStderrLine(
`warning: could not measure whether the review worktree is clean (git status failed: ` +
`warning: could not measure whether the review worktree is clean (reason: ` +
`${inertPath(residue.unmeasured)}). Every brief built by this call says so; an unmeasured tree is ` +
'not a clean one.',
);
Expand Down
Loading
Loading