Skip to content

Commit 95b2cf4

Browse files
wenshaoqwen-code-ci-botqwen-code-dev-bot
authored
fix(review): pin the verified git identity across the residue probe (#9557) (#9742)
* fix(review): pin the verified git identity across the residue probe (#9557) `worktreeResidue` verified a tree's identity once and then ran five commands that each re-discovered the repository from `cwd`, through the same `.git` file the check had read — a file writable by anything running as this user. Two halves, and they close different windows: - **The gate now requires the gitfile's admin entry to point BACK at this tree.** A repository whose `core.worktree` names this path answers `--show-toplevel` with this path, so the old gate saw itself while every command after it measured the plant's index — which already holds the contamination as committed content, so `status` comes back clean for a tree with a mutant in it. A planted standalone repo has no admin entry to round-trip; `scratch-tree` gates its own reset on exactly this. - **The verified `--git-dir`/`--work-tree` are pinned onto every later spawn**, so a swap that lands AFTER the gate cannot redirect them either. The reason recorded against doing this — that an explicit pin changes what `ls-files` resolves, so it needs its own round — was measured and is wrong. Across a standalone checkout, a linked worktree, a superproject with an initialised submodule, and a worktree reached through a symlinked ancestor, all five commands return **byte-identical** output pinned and unpinned. What was right about it is that it needed measuring, which is what this is. The backpointer half is pinned by a test that goes red without it. The pin half is not: it closes a check-then-use window the suite cannot open deterministically (the swap has to land between two spawns). Its evidence is a shell repro — with the identity captured, then the gitfile swapped, discovery reports a clean tree while the pinned commands still report ` M a.ts` and the untracked probe file. Saying that rather than implying a pin that is not there. * fix(review): refuse symlink redirects and forged admin entries in the residue probe (#9742) * fix(review): fail closed on unanchored clean verdicts and steered boundaries (#9742) The residue probe's identity gate answered CLEAN for shapes it cannot vouch for. Four closes, all fail-closed: - A repository answering for a path it does not contain — the common dir no literal ancestor of the tree path — is refused before the symlink walk. That is the shape a forge uses to steer the walk's own stop boundary so it fires before the planted link is lstat'd, and the walk's bound escaping to the filesystem root misdiagnosed refusals (and reddened the suite on hosts whose tmpdir resolves through a symlink). - A clean measurement no fetched-head record anchored is refused: a forged pair answers clean too, so an unanchored empty status certifies whichever index the gitfile names. Dirty reports still name paths — a forge answers clean, never dirty. - The agent-prompt wiring fails closed when the plan's fetchedSha is absent or malformed instead of degrading to an unpinned probe: every worktree-mode fetch writes the field. - A dangling admin-entry backpointer is "does not point back", not the outer catch's "not a git worktree". Comments now say what the pin actually closes (post-gate identity redirect, pre-planted shapes) and what it does not (in-window state presentation by an active writer — cost-raising, not closure; the structural close is the sandbox boundary, #9556). Witnessed: every guard flips its focused tests when removed (mutation probes), including new ones for the case-fold, the unreadable-HEAD arm, the sha handover seam, and the sha-less production caller. Fixtures realpath their roots so the suite holds under any tmpdir spelling. * fix(review): anchor the scratch-tree residue check and name unmeasured reasons (#9742) Three closes on the round-4 findings, all inside the residue probe's own footprint: - The sha-less production caller flattened the tampering signal the probe exists to protect: the no-record refusal fired on 100% of clean runs, so a note that fires always is a note nobody reads. scratch-tree now takes --fetched-sha, agent-prompt welds the plan's record into the verifier's command, and a healthy shared tree measures clean again — a forged pair is refused at the pin with a distinguishable reason instead. - The unmeasured renderers blamed `git status` for refusals where it never failed (the gates run before it; the no-record tail runs after it answered CLEAN), sending triagers to debug a git environment with nothing wrong. All three renderings now name a reason. - The ancestor-symlink walk had no witness: both sibling shapes refuse at earlier gates, and deleting the arm shipped green (measured). A shape that passes the leaf, the self-equality and the boundary gate leaves only the walk to refuse it. * test(review): pin the fetched-sha CLI contract and the weld continuation (#9742) * test(review): share the forge-territory fixture across the redirect residue tests (#9742) * fix(review): admit healthy trees spelled through links above the root (#9742) * fix(review): canonicalise both spellings the containment gate compares (#9742) * fix(review): admit healthy layouts and refuse unpinned or drifting trees (#9742) --------- Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
1 parent 3a9d2d3 commit 95b2cf4

6 files changed

Lines changed: 1221 additions & 57 deletions

File tree

packages/cli/src/commands/review/agent-prompt.test.ts

Lines changed: 173 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { execFileSync } from 'node:child_process';
2525
import {
2626
mkdirSync,
2727
mkdtempSync,
28+
realpathSync,
2829
rmSync,
2930
utimesSync,
3031
writeFileSync,
@@ -1479,7 +1480,7 @@ describe('--roster — every prompt the plan requires, in one call', () => {
14791480
// is launched, which makes this the one place the pipeline can notice that
14801481
// the tree those agents are about to read is not the commit they think it
14811482
// is. A real git worktree, because `git status` is the oracle.
1482-
const dir = mkdtempSync(join(tmpdir(), 'ap-residue-'));
1483+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'ap-residue-')));
14831484
// Ambient host git config (a global `commit.gpgsign` with no key, a
14841485
// `core.hooksPath` that fails) makes the fixture commit throw and reddens
14851486
// this test for reasons the branch never touched — the incident
@@ -1504,15 +1505,18 @@ describe('--roster — every prompt the plan requires, in one call', () => {
15041505
writeFileSync(join(wt, '__probe__.test.ts'), 'it("x", () => {});');
15051506

15061507
const plan = join(dir, 'plan.json');
1507-
writeFileSync(
1508-
plan,
1509-
JSON.stringify({
1510-
...PLAN,
1511-
worktreePath: wt,
1512-
prNumber: '9207',
1513-
ownerRepo: 'QwenLM/qwen-code',
1514-
}),
1515-
);
1508+
const writePlan = (fields: Record<string, unknown>) =>
1509+
writeFileSync(
1510+
plan,
1511+
JSON.stringify({
1512+
...PLAN,
1513+
worktreePath: wt,
1514+
prNumber: '9207',
1515+
ownerRepo: 'QwenLM/qwen-code',
1516+
...fields,
1517+
}),
1518+
);
1519+
writePlan({ fetchedSha: git('rev-parse', 'HEAD').trim() });
15161520
(agentPromptCommand.handler as (a: unknown) => void)({
15171521
plan,
15181522
roster: true,
@@ -1533,12 +1537,135 @@ describe('--roster — every prompt the plan requires, in one call', () => {
15331537
expect(readFileSync(briefPath(plan, '1b'), 'utf8')).toContain(
15341538
'And right now it is not clean',
15351539
);
1540+
1541+
// The handover is the wiring under test: drop it and the brief degrades
1542+
// in one of two ways, both refused — a WRONG sha (the forge's own)
1543+
// reaches the pin and is refused there, a MISSING one fails closed
1544+
// before the probe runs, because every worktree-mode fetch writes the
1545+
// field and its absence means the plan was tampered with. Either way
1546+
// the brief carries the unmeasured sentence, never a clean verdict.
1547+
const briefOf = (fields: Record<string, unknown>) => {
1548+
writePlan(fields);
1549+
(agentPromptCommand.handler as (a: unknown) => void)({
1550+
plan,
1551+
roster: true,
1552+
});
1553+
return readFileSync(briefPath(plan, '1a'), 'utf8');
1554+
};
1555+
const wrongSha = briefOf({ fetchedSha: `deadbeef${'0'.repeat(32)}` });
1556+
expect(wrongSha).toContain('Whether it is clean could not be measured');
1557+
expect(wrongSha).toContain('not the fetched PR head');
1558+
// The framing names a reason, not a failed `git status` — the status
1559+
// never ran for these refusals, and a triager sent to debug the git
1560+
// environment would find nothing to fix.
1561+
expect(wrongSha).toContain('(reason: ');
1562+
expect(wrongSha).not.toContain('(`git status` failed');
1563+
const noSha = briefOf({});
1564+
expect(noSha).toContain('Whether it is clean could not be measured');
1565+
expect(noSha).toContain('no usable record of the fetched head sha');
1566+
// The stderr warning the handler prints for the same state carries the
1567+
// same neutral framing.
1568+
expect(writeStderrLine).toHaveBeenCalledWith(
1569+
expect.stringContaining('(reason: '),
1570+
);
15361571
} finally {
15371572
rmSync(dir, { recursive: true, force: true });
15381573
gitIsolation.dispose();
15391574
}
15401575
});
15411576

1577+
// A SHA-256 repository is the shape the record validators must admit:
1578+
// fetch-pr writes `git rev-parse` verbatim, and in that repository class
1579+
// the answer is 64 hex. Git grew the format late, so probe for support and
1580+
// skip where it is absent rather than fail a host that cannot build the
1581+
// fixture.
1582+
const gitSha256Supported = (() => {
1583+
try {
1584+
const probe = mkdtempSync(join(tmpdir(), 'qwen-sha256-probe-'));
1585+
try {
1586+
execFileSync('git', ['init', '-q', '--object-format=sha256', probe], {
1587+
stdio: 'pipe',
1588+
});
1589+
return true;
1590+
} finally {
1591+
rmSync(probe, { recursive: true, force: true });
1592+
}
1593+
} catch {
1594+
return false;
1595+
}
1596+
})();
1597+
1598+
it.skipIf(!gitSha256Supported)(
1599+
'pins a SHA-256 review worktree with the plan’s 64-hex record',
1600+
() => {
1601+
// A validator matching only 40-hex shas drops the record this
1602+
// repository class writes: every worktree-mode round then fails
1603+
// closed as though the plan were tampered with, and the verifier's
1604+
// scratch-tree command is built without `--fetched-sha`. The 64-hex
1605+
// record must reach BOTH the residue pin and the welded command.
1606+
const gitIsolation = isolateHostGitConfig();
1607+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'ap-sha256-')));
1608+
try {
1609+
const git = (...args: string[]) =>
1610+
execFileSync('git', args, { cwd: dir, encoding: 'utf8' });
1611+
git('init', '-q', '-b', 'main', '--object-format=sha256');
1612+
git('config', 'user.email', 't@t.t');
1613+
git('config', 'user.name', 't');
1614+
writeFileSync(join(dir, 'a.ts'), 'export const x = 1;\n');
1615+
git('add', '-A');
1616+
git('commit', '-qm', 'head');
1617+
const sha64 = git('rev-parse', 'HEAD').trim();
1618+
expect(sha64).toMatch(/^[0-9a-f]{64}$/);
1619+
const wt = join(dir, '.qwen', 'tmp', 'review-pr-sha256');
1620+
git('worktree', 'add', '--detach', '-q', wt, 'HEAD');
1621+
const plan = join(dir, 'plan.json');
1622+
writeFileSync(
1623+
plan,
1624+
JSON.stringify({
1625+
...PLAN,
1626+
worktreePath: wt,
1627+
prNumber: '256',
1628+
ownerRepo: 'QwenLM/qwen-code',
1629+
fetchedSha: sha64,
1630+
}),
1631+
);
1632+
(agentPromptCommand.handler as (a: unknown) => void)({
1633+
plan,
1634+
roster: true,
1635+
});
1636+
1637+
// The record reached the residue pin: the tree at the recorded sha
1638+
// measures clean instead of being refused for a missing record.
1639+
const brief = readFileSync(briefPath(plan, '1a'), 'utf8');
1640+
expect(brief).not.toContain(
1641+
'Whether it is clean could not be measured',
1642+
);
1643+
expect(brief).not.toContain('no usable record of the fetched head');
1644+
// And it reached the scratch-tree command welded into a verifier
1645+
// shard's brief — shards launch through the single-role path with
1646+
// their record key, exactly as the orchestrator runs them.
1647+
const findings = join(dir, 'findings.md');
1648+
writeFileSync(findings, '- **[Critical]** probe');
1649+
(agentPromptCommand.handler as (a: unknown) => void)({
1650+
plan,
1651+
role: 'verify',
1652+
findings,
1653+
});
1654+
const recorded = readRecordedPrompts(plan);
1655+
const verifyKey = [...recorded.keys()].find((k) =>
1656+
k.startsWith('verify--'),
1657+
);
1658+
expect(verifyKey).toBeDefined();
1659+
expect(
1660+
readFileSync(briefPath(plan, verifyKey ?? ''), 'utf8'),
1661+
).toContain(`--fetched-sha ${sha64}`);
1662+
} finally {
1663+
rmSync(dir, { recursive: true, force: true });
1664+
gitIsolation.dispose();
1665+
}
1666+
},
1667+
);
1668+
15421669
it('builds and records the whole 3A roster', () => {
15431670
const dir = mkdtempSync(join(tmpdir(), 'ap-roster-'));
15441671
try {
@@ -2839,6 +2966,42 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
28392966
expect(
28402967
buildRoleBrief(PR_PLAN, 'verify', { key: 'verify; rm -rf /' }),
28412968
).toContain('--label verify__rm_-rf__');
2969+
// The plan's fetched sha rides along when the plan carries a usable one:
2970+
// it is the shared-tree residue check's identity anchor, and without it
2971+
// the check would refuse every healthy run (#9742). Absent or malformed,
2972+
// nothing is welded — the record-less refusal is the fail-closed shape.
2973+
const sha = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
2974+
// Pin the JOINED fragment, not the bare flag: without the continuation
2975+
// after `--label` the snippet is two statements — the command runs
2976+
// unpinned and the sha line dies as command-not-found — while
2977+
// `toContain('--fetched-sha …')` still passes.
2978+
expect(
2979+
buildRoleBrief({ ...PR_PLAN, fetchedSha: sha }, 'verify', {
2980+
key: 'verify--round-2--deadbeef1234',
2981+
}),
2982+
).toContain(
2983+
`--label verify--round-2--deadbeef1234 \\
2984+
--fetched-sha ${sha}`,
2985+
);
2986+
// A SHA-256 repository records a 64-hex commit; the pipeline's own
2987+
// shape contract admits both full object-ID lengths, so that record
2988+
// welds in too — a validator that only matched 40 hex would leave
2989+
// every SHA-256 review's command unpinned.
2990+
const sha256 = 'ab'.repeat(32);
2991+
expect(
2992+
buildRoleBrief({ ...PR_PLAN, fetchedSha: sha256 }, 'verify', {
2993+
key: 'verify--round-2--deadbeef1234',
2994+
}),
2995+
).toContain(`--fetched-sha ${sha256}`);
2996+
// And the sha-less brief must not carry a continuation after the label
2997+
// either — a dangling one would glue the closing fence onto the command.
2998+
expect(p).not.toMatch(/--label verify--round-2--deadbeef1234 \\/);
2999+
expect(p).not.toContain('--fetched-sha');
3000+
expect(
3001+
buildRoleBrief({ ...PR_PLAN, fetchedSha: 'not-a-sha' }, 'verify', {
3002+
key: 'verify--round-2--deadbeef1234',
3003+
}),
3004+
).not.toContain('--fetched-sha');
28423005
// No worktree, no scratch tree — a local or cross-repo review has no
28433006
// pristine sibling to build, and HEAD is not what is under review there.
28443007
expect(buildRoleBrief(PLAN, 'verify')).not.toContain('review scratch-tree');

packages/cli/src/commands/review/agent-prompt.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,11 @@ import { SHA_RE } from './lib/ledger.js';
9999
import { pathRulesFor } from './lib/path-rules.js';
100100
import { shellQuotePath } from './lib/shell-quote.js';
101101
import { inertPath, scratchLabel } from './lib/paths.js';
102-
import { worktreeResidue, type WorktreeResidue } from './lib/worktree.js';
102+
import {
103+
RESIDUE_PATH_CAP,
104+
worktreeResidue,
105+
type WorktreeResidue,
106+
} from './lib/worktree.js';
103107
import {
104108
isTerritoryFanOut,
105109
requiredAgents,
@@ -151,6 +155,8 @@ interface PlanReport {
151155
prNumber?: unknown;
152156
ownerRepo?: unknown;
153157
worktreePath?: unknown;
158+
/** The PR head sha fetch-pr recorded — the probe's identity anchor. */
159+
fetchedSha?: unknown;
154160
mergeBaseSha?: unknown;
155161
host?: unknown;
156162
repositoryContext?: unknown;
@@ -1316,6 +1322,27 @@ function repositoryContextBlock(context: RepositoryContext): string[] {
13161322
];
13171323
}
13181324

1325+
/**
1326+
* The plan's fetched head sha when it carries a usable one. Absent or
1327+
* malformed answers nothing rather than a broken anchor: every worktree-mode
1328+
* fetch writes the field, so both call sites fail closed on that absence,
1329+
* each in its own way.
1330+
*
1331+
* A usable one is a FULL Git object ID: 40 hex for SHA-1 repositories and
1332+
* 64 for SHA-256 ones — fetch-pr records `git rev-parse` verbatim, and the
1333+
* pipeline's own shape contract admits both lengths (pr-context's
1334+
* COMMIT_SHA_RE carries its {40,64} breadth for exactly that class). A
1335+
* validator matching only the SHA-1 length would drop the record every
1336+
* SHA-256 review writes, failing closed as though the plan were tampered
1337+
* with and welding an unpinned scratch-tree command.
1338+
*/
1339+
function fetchedShaOf(report: PlanReport): string | undefined {
1340+
const sha = report.fetchedSha;
1341+
return typeof sha === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)
1342+
? sha
1343+
: undefined;
1344+
}
1345+
13191346
/**
13201347
* The review worktree's residue, or nothing at all when there is no worktree to
13211348
* have any. Resolved against the process cwd, like every other use of
@@ -1325,7 +1352,28 @@ function repositoryContextBlock(context: RepositoryContext): string[] {
13251352
function worktreeResidueOf(report: PlanReport): WorktreeResidue {
13261353
const wt = report.worktreePath;
13271354
if (typeof wt !== 'string' || !wt) return { paths: [], total: 0 };
1328-
return worktreeResidue(resolve(wt));
1355+
// Hand over the sha fetch-pr recorded: committing the contamination moves
1356+
// a forge's HEAD off it, so with it the probe refuses a forged admin entry
1357+
// (see worktreeResidue). The record raises the plant's cost; it does not
1358+
// make planting impossible — it is re-read from the plan file at every
1359+
// invocation, and a same-user writer can rewrite it along with the forge.
1360+
// Absent or malformed it fails CLOSED: every worktree-mode fetch writes
1361+
// the field, so a plan that names a worktree without it is tampered or
1362+
// corrupted, and measuring unpinned would certify whichever index the
1363+
// gitfile names.
1364+
const sha = fetchedShaOf(report);
1365+
if (sha === undefined) {
1366+
return {
1367+
paths: [],
1368+
total: 0,
1369+
unmeasured:
1370+
'the plan carries no usable record of the fetched head sha — ' +
1371+
'every worktree-mode fetch writes one, so its absence means ' +
1372+
'tampering or corruption, and measuring without it would certify ' +
1373+
'whichever index the .git gitfile names',
1374+
};
1375+
}
1376+
return worktreeResidue(resolve(wt), RESIDUE_PATH_CAP, sha);
13291377
}
13301378

13311379
/**
@@ -1380,7 +1428,7 @@ function worktreeEvidenceBlock(
13801428
if (residue?.unmeasured) {
13811429
parts.push(
13821430
'',
1383-
`**Whether it is clean could not be measured** (\`git status\` failed: ` +
1431+
`**Whether it is clean could not be measured** (reason: ` +
13841432
`${inertPath(residue.unmeasured)}). That is not the same as clean: treat ` +
13851433
'anything that surprises you in this tree as unverified until you have ' +
13861434
'checked it against `git show HEAD:<path>`.',
@@ -1649,6 +1697,11 @@ export function buildRoleBrief(
16491697
// written into a shell command, and the one function that decides the
16501698
// tree's name is also what keeps a metacharacter out of that command.
16511699
const label = scratchLabel(opts.key ?? role);
1700+
// The identity anchor fetch-pr recorded, when the plan carries a usable
1701+
// one: with it the probe pins the shared tree and a healthy run measures
1702+
// clean — without it the no-record refusal fires on every run, and a
1703+
// tampering note that fires always is a note nobody reads.
1704+
const sha = fetchedShaOf(report);
16521705
parts.push(
16531706
'',
16541707
'**Your scratch tree — where every probe, mutant and candidate fix goes.** ' +
@@ -1667,7 +1720,8 @@ export function buildRoleBrief(
16671720
// a bare interpolation, and the failure would be silent — every shard's
16681721
// scratch tree unavailable, every probe demoted to a reading.
16691722
`"\${QWEN_CODE_CLI:-qwen}" review scratch-tree --worktree ${shellQuotePath(resolve(wt))} \\`,
1670-
` --label ${label}`,
1723+
` --label ${label}${sha === undefined ? '' : ' \\'}`,
1724+
...(sha === undefined ? [] : [` --fetched-sha ${sha}`]),
16711725
'```',
16721726
'',
16731727
'It reports `path` — work there, and leave what you leave: `cleanup` sweeps ' +
@@ -3212,7 +3266,7 @@ function runAgentPrompt(args: AgentPromptArgs): void {
32123266
const residue = worktreeResidueOf(report);
32133267
if (residue.unmeasured) {
32143268
writeStderrLine(
3215-
`warning: could not measure whether the review worktree is clean (git status failed: ` +
3269+
`warning: could not measure whether the review worktree is clean (reason: ` +
32163270
`${inertPath(residue.unmeasured)}). Every brief built by this call says so; an unmeasured tree is ` +
32173271
'not a clean one.',
32183272
);

0 commit comments

Comments
 (0)