Skip to content
Merged
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
ed2ef50
fix(review): keep host-trusted state out of the container's writable …
wenshao Aug 25, 2026
4ae341e
fix(review): close the shapes the first cut of this gate left open
wenshao Aug 25, 2026
9213610
fix(review): gate the third write, and stop breaking the lane that pi…
wenshao Aug 25, 2026
895462e
test(review): gate the new cases off the platform where containment c…
wenshao Aug 25, 2026
3d33de5
test(review): assert the property, not the sentence that explains it
wenshao Aug 25, 2026
a5fbb49
Merge branch 'main' into fix/review-host-state-out-of-mount
wenshao Aug 25, 2026
b3ff0b1
fix(review): ask git where the repository is, and gate the entrances …
wenshao Aug 25, 2026
226c1fb
Merge remote-tracking branch 'origin/fix/review-host-state-out-of-mou…
wenshao Aug 25, 2026
e89cdd0
fix(review): the entrances round 6 found, and two limits stated rathe…
wenshao Aug 25, 2026
36fde42
fix(review): stop editing git's answer, and gate the pointer that is …
wenshao Aug 26, 2026
78bb556
fix(review): let the launch directory resolve the way git does
wenshao Aug 26, 2026
548887a
fix(review): three failures of my own, and the block gate that stops …
wenshao Aug 26, 2026
ae424f1
Merge remote-tracking branch 'origin/main' into fix/review-host-state…
wenshao Aug 29, 2026
9b89f6a
Merge branch 'main' into fix/review-host-state-out-of-mount
wenshao Aug 30, 2026
1cba501
Merge branch 'main' into fix/review-host-state-out-of-mount
wenshao Aug 30, 2026
7455996
Merge remote-tracking branch 'origin/main' into fix/review-host-state…
wenshao Aug 31, 2026
a94be7b
fix(review): gate the reads and the launch directories, not only the …
wenshao Sep 2, 2026
049d6bb
fix(review): gate the base tree's reuse fast path too
wenshao Sep 2, 2026
3f10c3f
fix(review): ask the launch-directory question where all of it goes t…
wenshao Sep 2, 2026
29b6aae
refactor(review): let the gates default to the mount root everywhere
wenshao Sep 2, 2026
6346aea
fix(review): gate revert-hunk's tree reads, and reflow a comment
wenshao Sep 2, 2026
8ac40a1
Merge remote-tracking branch 'origin/main' into fix/review-host-state…
wenshao Sep 2, 2026
4fba588
Merge remote-tracking branch 'origin/main' into fix/review-host-state…
wenshao Sep 2, 2026
5b0b78c
fix(review): let a refusal say it refused, and re-ask where git resolves
wenshao Sep 2, 2026
5b8744c
fix(review): ask a pointer where it resolves to, and stamp the run th…
wenshao Sep 2, 2026
c6a5ac8
Merge branch 'main' into fix/review-host-state-out-of-mount
qwen-code-dev-bot Sep 3, 2026
8f881f0
fix(review): fail closed where a gate could not be run, and stop edit…
wenshao Sep 3, 2026
ca37195
Merge branch 'main' into fix/review-host-state-out-of-mount
qwen-code-dev-bot Sep 3, 2026
f4fcaa8
Merge branch 'main' into fix/review-host-state-out-of-mount
qwen-code-dev-bot Sep 3, 2026
f477327
Merge branch 'main' into fix/review-host-state-out-of-mount
qwen-code-dev-bot Sep 4, 2026
da52bea
Merge branch 'main' into fix/review-host-state-out-of-mount
qwen-code-dev-bot Sep 4, 2026
28da51e
Merge branch 'main' into fix/review-host-state-out-of-mount
qwen-code-dev-bot Sep 4, 2026
ac2e3ee
Merge branch 'main' into fix/review-host-state-out-of-mount
wenshao Sep 5, 2026
ba3ba06
fix(review): fail cleanup's branch probe on any non-answer, survive a…
wenshao Sep 8, 2026
8d33be5
fix(review): walk --tree's ancestors for a planted symlink, not only …
wenshao Sep 8, 2026
56b4abd
fix(review): mirror new leases at the legacy path, bound the legacy read
wenshao Sep 8, 2026
e97c734
fix(review): fence base-tree reuse with a per-run secret kept outside…
wenshao Sep 8, 2026
c974879
Merge remote-tracking branch 'origin/main' into fix/review-host-state…
wenshao Sep 8, 2026
2a4e47f
fix(review): fail the trust gates closed when the mount root is refused
wenshao Sep 8, 2026
5e83ae1
fix(review): round-trip the admin entry's backpointer in the location…
wenshao Sep 8, 2026
3cc9638
fix(review): count a lossy decode of git's answer as no answer (R8-2)
wenshao Sep 8, 2026
4747901
fix(review): fail the prune arm when git could not be run at all (R23-8)
wenshao Sep 8, 2026
3c295fd
test(review): type the lease-read mocks with their parameters
wenshao Sep 8, 2026
2bb3893
fix(review): close what the trust-artifact move's first pass missed
wenshao Sep 8, 2026
414557e
Merge commit '1f890086f1a41e4de7965c44281116fd65b695d8' into fix/revi…
wenshao Sep 9, 2026
0d0098b
fix(review): merge main; pin the legacy cutoff and isolate the finali…
qwen-code-ci-bot Sep 9, 2026
fa8e9c0
fix(review): close the audit's second pass — witness gaps and one sub…
wenshao Sep 9, 2026
30ae483
Merge branch 'fix/review-host-state-out-of-mount' of github.com:QwenL…
wenshao Sep 9, 2026
ed3c90b
fix(review): anchor the scratch sweep at the captured root (R30-6)
wenshao Sep 9, 2026
e1c24ba
fix(review): the base-tree fence reads only host-side state
wenshao Sep 9, 2026
f4ab0cf
fix(review): the mount-null is three answers, and the gates split them
wenshao Sep 9, 2026
dd0b82a
fix(review): the legacy lease is never believed, the mirror never fatal
wenshao Sep 9, 2026
582e5c8
fix(review): judge the launch directory where the kernel stands (R30-47)
wenshao Sep 9, 2026
1b00e4f
fix(review): the second audit round's findings on the batch itself
wenshao Sep 9, 2026
f5bc23d
Merge branch 'main' into fix/review-host-state-out-of-mount
qwen-code-dev-bot Sep 9, 2026
92d9438
fix(review): the mirror's tmp cleanup must not throw either
wenshao Sep 9, 2026
2a02eb2
Merge branch 'fix/review-host-state-out-of-mount' of github.com:QwenL…
wenshao Sep 9, 2026
38052f3
fix(review): import rmSync in the trust store, type the rmSync mock's…
wenshao Sep 9, 2026
1aedd0b
refactor(review): split the base-tree reuse fence out of this PR
Sep 10, 2026
9ca8216
fix(review): the seven Criticals the split leaves behind
Sep 10, 2026
a53c473
Merge branch 'main' into fix/review-host-state-out-of-mount
Sep 10, 2026
546ee81
Merge branch 'main' into fix/review-host-state-out-of-mount
Sep 10, 2026
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
9 changes: 8 additions & 1 deletion .github/workflows/qwen-code-pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3024,7 +3024,14 @@ jobs:
"${GIT_SAFE[@]}" branch -D "$review_ref" ||
echo "::warning::could not remove review branch: $review_ref"
done || true
rm -f .qwen/tmp/qwen-review-lease-pr-*.json 2>/dev/null || true
# Both locations: leases moved to `.qwen/review-leases` (out of the
# directory the review sandbox mounts read-write), and a runner whose
# workspace persists can still be holding one written by an older
# build in the old place. `-r`, because the old path is inside that
# mounted directory and a reviewed PR can leave a DIRECTORY at the
# lease's name, which plain `rm -f` cannot remove.
rm -rf .qwen/review-leases/qwen-review-lease-pr-*.json 2>/dev/null || true
rm -rf .qwen/tmp/qwen-review-lease-pr-*.json 2>/dev/null || true
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated
echo "review worktrees cleaned"

# A review job that dies abnormally — runner crash, host loss, or the
Expand Down
323 changes: 320 additions & 3 deletions packages/cli/src/commands/review/base-tree.test.ts

Large diffs are not rendered by default.

196 changes: 179 additions & 17 deletions packages/cli/src/commands/review/base-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,19 @@ import { dirname, join, resolve } from 'node:path';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { baseWorktreePath } from './lib/paths.js';
import {
untrustedGitfile,
discardWorktree,
sanitizedGitEnv,
worktreeCreateFailureDetail,
type SweepResult,
} from './lib/worktree.js';
import { runBuildTest, type BuildTestReport } from './build-test.js';
import {
baseTreeTrustPath,
builtTreeRecord,
recordBuiltTree,
runNonce,
} from './lib/base-tree-trust.js';

export interface BaseTreeReport {
/**
Expand Down Expand Up @@ -107,6 +114,22 @@ function gitOut(cwd: string, ...args: string[]): string {
return (r.stdout ?? '').trim();
}

// The NUL-delimited form: `gitOut`'s `.trim()` would eat a leading-space
// filename and the record separator is NUL, not whitespace.
function gitOutZ(cwd: string, ...args: string[]): string {
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated
const r = spawnSync('git', args, {
cwd,
Comment thread
wenshao marked this conversation as resolved.
Outdated
encoding: 'utf8',
env: sanitizedGitEnv(),
});
if (r.error) throw r.error;
if (r.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${r.stderr ?? ''}`);
}
const out = r.stdout ?? '';
return out.endsWith('\0') ? out.slice(0, -1) : out;
}

function git(cwd: string, ...args: string[]): void {
const r = spawnSync('git', args, {
cwd,
Expand All @@ -119,6 +142,35 @@ function git(cwd: string, ...args: string[]): void {
}
}

// The tree's untracked AND ignored FILES, listed individually. Two
// `ls-files` calls rather than `git status`: status collapses a directory to
// one `dir/` entry, and a plant dropped INSIDE a directory the build left —
// `dist/cli.js`, `node_modules/.bin/<x>`, exactly what a host-side A/B
// executes — then changes no set membership. `ls-files --others` never
// collapses. The residual, by design: content rewritten IN PLACE at a
// recorded path changes no membership either — the path-set limit stated at
// the subset check below.
function untrackedPaths(tree: string): string[] {
const others = gitOutZ(
tree,
'ls-files',
'-z',
'--others',
'--exclude-standard',
);
const ignored = gitOutZ(
tree,
'ls-files',
'-z',
'--others',
'--ignored',
'--exclude-standard',
);
Comment thread
wenshao marked this conversation as resolved.
Outdated
return [...others.split('\0'), ...ignored.split('\0')]
.filter((e) => e !== '')
.sort();
}

export function runBaseTree(args: BaseTreeArgs): BaseTreeReport {
const unavailable = (note: string): BaseTreeReport => ({
available: false,
Expand Down Expand Up @@ -175,20 +227,106 @@ export function runBaseTree(args: BaseTreeArgs): BaseTreeReport {
// rebase between runs — falls through to the rebuild below.)
const marker = () => join(tree, '.qwen-review-base-ok');
const failedMarker = () => join(tree, '.qwen-review-base-failed');
// The run's stamp secret, persisted OUTSIDE the mount beside the worktree
// leases (see lib/base-tree-trust.ts). The fence used to key on the plan's
// mtime, and the plan lives inside the directory the sandbox mounts
// read-write — reviewed code could stat it, compute the epoch, and write
// it into a forged stamp, after which every reuse check passed on an
// attacker-shaped tree. A random per-run secret the mount can neither
// read nor write is what a stamp now vouches with. Without it no marker
// can be trusted or written, so its absence fails the command, not the
// fence.
const trustPath = baseTreeTrustPath(worktree, args.plan);
let nonce: string;
Comment thread
wenshao marked this conversation as resolved.
Outdated
try {
if (
existsSync(tree) &&
readFileSync(marker(), 'utf8').trim() === baseSha &&
gitOut(tree, 'rev-parse', 'HEAD') === baseSha
) {
return {
available: true,
path: tree,
baseSha,
build: null,
note: `base tree already built at ${baseSha.slice(0, 9)} in ${tree} (reusing it — a concurrent or earlier probe built it)`,
};
nonce = runNonce(trustPath);
} catch (err) {
return unavailable(
`could not establish the run's trust artifact ${trustPath}: ` +
`${(err as Error).message}`,
);
}
try {
const stamp = readFileSync(marker(), 'utf8').trim().split('\n');
if (existsSync(tree) && stamp[0] === baseSha && stamp[1] === nonce) {
// ...and THIS run built it — genuinely: the secret never enters the
// mount, so a mount-local writer cannot have refreshed or forged the
// stamp. `cleanStale` never releases `-base`, so an earlier round's
// tree stands into this one with a whole containerized build/test
// phase in between — long enough for the reviewed code to drop
// untracked executables in here, and `dist/cli.js` is what a host-side
// A/B runs.
// The reuse path RETURNS, so the gate below the rebuild never runs
// for it — and both facts these arms establish come from inside the
// mount: `rev-parse HEAD` resolves through that tree's own `.git`,
// and its working files are a direct child of the directory the
// sandbox mounts read-write. A planted repository answers the right
// sha for a working tree holding anything at all; a plain copy
// overwrites the tracked sources while HEAD never moves — and the
// A/B's BASE side is then the reviewed code's own, so a test the PR
// breaks fails identically on both sides and reads as pre-existing.
const pointerWhy = untrustedGitfile(tree);
if (
pointerWhy !== null ||
gitOut(tree, 'rev-parse', 'HEAD') !== baseSha
) {
// A rewritten pointer or a moved HEAD has NO benign cause: this
// run's build does not touch either, and a concurrent A/B does not
// move HEAD. Not the busy arm — the discard-and-rebuild below is
// exactly what sweeps the plant, and declining here would leave it
// standing for the rest of the run.
} else {
const recorded = builtTreeRecord(trustPath, tree);
Comment thread
wenshao marked this conversation as resolved.
Outdated
if (recorded === null || recorded.baseSha !== baseSha) {
// A torn or stale record is not a live tree's state — the rebuild
// below self-heals it.
} else if (
// Tracked dirt OR an untracked addition. Both have benign causes
// on a tree this run built: codegen and lockfile rewrites from
// this run's own build, a concurrent shard's snapshot `--update`
// mid-A/B, the A/B's own cache output. Both are also what a plant
// looks like — and that ambiguity is exactly why the answer is to
// DECLINE rather than to discard: discarding on this signal
// sweeps a live tree another shard may be mid-A/B in (the
// concurrent-shard clobber this fast path exists to prevent),
// while a genuine plant wedged here is never executed, and the
// next run's fresh nonce discards it.
//
// `--untracked-files=no` for the tracked arm: the pipeline's own
// build leaves `node_modules/` and `dist/` here, so an
// untracked-inclusive check would call every correctly-built
// tree dirty and disable reuse outright — the untracked surface
// is the subset arm's, recorded host-side at marker write (a path
// SET per the finding's prescription; content rewritten in place
// at a recorded path changes no membership, stated not hidden).
gitOut(tree, 'status', '--porcelain', '--untracked-files=no') !==
Comment thread
wenshao marked this conversation as resolved.
Outdated
'' ||
!untrackedPaths(tree).every((p) => recorded.untracked.includes(p))
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated
) {
return unavailable(
`the base tree at ${baseSha.slice(0, 9)} was built by this run ` +
'but no longer passes a reuse check (a concurrent probe may be ' +
'writing it mid-A/B); declining to reuse or discard it — retry ' +
'when the probe finishes, or settle the claim by reading',
);
} else {
return {
available: true,
path: tree,
baseSha,
build: null,
note: `base tree already built at ${baseSha.slice(0, 9)} in ${tree} (reusing it — a concurrent or earlier probe built it)`,
};
}
}
}
// No marker, or a stamp that does not carry this run's secret — an
// earlier run's leftover, or a forge computed from the mount-visible
// inputs. Not a refusal: an unusable leftover is what the rebuild exists
// for. Falling through discards the tree (removing a plant with it) and
// creates a fresh one through the review worktree's pointer, which the
// gate before `worktree add` checks. Same shape as `scratch-tree`'s
// reuse path, for the same reason.
} catch {
// No marker, unreadable marker, or a tree git cannot answer for: rebuild.
}
Expand All @@ -197,9 +335,16 @@ export function runBaseTree(args: BaseTreeArgs): BaseTreeReport {
// the same "unavailable" — and the sweep destroys the evidence tree the
// failure deliberately leaves standing.
try {
const failed = readFileSync(failedMarker(), 'utf8').trim().split('\n');
if (
existsSync(tree) &&
readFileSync(failedMarker(), 'utf8').trim() === baseSha
failed[0] === baseSha &&
// The same fence the ok marker carries, on the path that needs it most:
// this branch settles the question with NO build at all, and the file
// lives inside the mount — a sha-only marker is one planted line away
// from suppressing the A/B lane for the whole round, reading as
// infrastructure rather than as an attack.
failed[1] === nonce
) {
return {
available: false,
Expand Down Expand Up @@ -265,6 +410,16 @@ export function runBaseTree(args: BaseTreeArgs): BaseTreeReport {
// Clear a stale base tree left by a crashed run — it would fail `add`. Its
// stderr is kept, because it is usually what explains that failure.
sweep = discardWorktree(worktree, tree);
// The same question the probe phase asks before its own `worktree add`:
// this resolves the repository through the REVIEW worktree's gitfile,
// which lives inside the directory the sandbox mounts read-write and
// which the build/test phase already gave the reviewed code a chance to
// rewrite. `worktree add` checks files out, so it runs whatever that
// pointer leads to, on the host. See `untrustedGitfile`.
const untrusted = untrustedGitfile(worktree);
if (untrusted !== null) {
throw new Error(`refusing to create a base tree: ${untrusted}`);
}
git(worktree, 'worktree', 'add', '--detach', tree, baseSha);
} catch (e) {
return unavailable(
Expand Down Expand Up @@ -316,9 +471,11 @@ export function runBaseTree(args: BaseTreeArgs): BaseTreeReport {
// Leave the tree standing. A base that does not build is a fact worth
// looking at by hand, and deleting the evidence to save a directory is a
// bad trade — `cleanup` sweeps it at the end of the review either way.
// The marker makes the failure a SETTLED answer for every later shard.
// The marker makes the failure a SETTLED answer for every later shard,
// and it carries the run secret for the same reason the ok marker does:
// a planted sha-only marker must settle nothing (the fence above).
try {
writeFileSync(failedMarker(), `${baseSha}\n`);
writeFileSync(failedMarker(), `${baseSha}\n${nonce}\n`);
} catch {
// The tree may be too broken to hold a marker; the next shard repays.
}
Expand All @@ -335,9 +492,14 @@ export function runBaseTree(args: BaseTreeArgs): BaseTreeReport {
}

// The marker is what the fast path above trusts, so it is written only after
// a build that succeeded, and it records the SHA it vouches for.
// a build that succeeded, and it records the SHA and the run it vouches for
// — the run secret, not the mount-visible epoch it replaced. With it goes
// the host-side record of the untracked set this build legitimately left,
// so the reuse fence can tell that residue from a plant dropped later
// (marker first, so the marker itself is part of the recorded set).
try {
writeFileSync(marker(), `${baseSha}\n`);
writeFileSync(marker(), `${baseSha}\n${nonce}\n`);
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated
recordBuiltTree(trustPath, tree, baseSha, untrackedPaths(tree));
Comment thread
wenshao marked this conversation as resolved.
Outdated
} catch {
// The tree may be too broken to hold a marker; the next shard rebuilds.
}
Expand Down
Loading
Loading