Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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 @@ -1971,7 +1971,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.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
echo "review worktrees cleaned"

# A review job that dies abnormally — runner crash, host loss, or the
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/commands/review/base-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
mkdirSync,
rmSync,
writeFileSync,
cpSync,
existsSync,
readFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -43,6 +45,13 @@ const failedBuild = {
build: [{ command: 'npm run build', exitCode: 2 }],
} as unknown as BuildTestReport;

// Skipped on win32 for the same reason as the sibling suites: `mountRootFor`
// refuses every absolute Windows path (a drive letter is a colon), so
// containment is unavailable there by design and this gate never speaks. The
// assertion would fail for that reason and nothing else — first inside the
// merge queue, where that lane actually runs.
const itWhereContainmentExists = it.skipIf(process.platform === 'win32');
Comment thread
wenshao marked this conversation as resolved.
Resolved

describe('runBaseTree', () => {
let repo: string;
let worktree: string;
Expand Down Expand Up @@ -96,6 +105,34 @@ describe('runBaseTree', () => {

afterEach(() => rmSync(repo, { recursive: true, force: true }));

itWhereContainmentExists(
'refuses to build through a rewritten review-worktree gitfile',
() => {
// `worktree add` resolves the repository through the REVIEW worktree's own
// gitfile, which lives in the directory the sandbox mounts read-write and
// which the build/test phase already ran the PR's code against. It checks
// files out, so it runs whatever that pointer leads to, on the host.
const planted = join(repo, '.qwen', 'tmp', '.evil-git');
const real = readFileSync(join(worktree, '.git'), 'utf8')
.trim()
.replace('gitdir: ', '');
cpSync(real, planted, { recursive: true });
writeFileSync(join(planted, 'commondir'), `${join(repo, '.git')}\n`);
writeFileSync(
join(planted, 'gitdir'),
`gitdir: ${join(worktree, '.git')}\n`,
);
writeFileSync(join(worktree, '.git'), `gitdir: ${planted}\n`);

const r = run();
expect(r.available).toBe(false);
expect(JSON.stringify(r)).toContain('review temp dir');
// The tree was never created, which is what says the spawn never ran —
// the note alone reads the same whichever side of it the gate fires on.
expect(existsSync(baseWorktreePath(worktree))).toBe(false);
},
);

it('creates a sibling worktree holding the BASE commit, not the head', () => {
const r = run();
expect(r.available).toBe(true);
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/commands/review/base-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,13 @@ 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 { mountRootFor } from './lib/sandboxed-exec.js';
import { runBuildTest, type BuildTestReport } from './build-test.js';

export interface BaseTreeReport {
Expand Down Expand Up @@ -265,6 +267,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, mountRootFor);
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
2 changes: 1 addition & 1 deletion packages/cli/src/commands/review/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ vi.mock('../../services/review-worktree-lease.js', () => ({
readReviewWorktreeLease: mocks.readReviewWorktreeLease,
reviewLeaseHeldByAnotherSession: mocks.reviewLeaseHeldByAnotherSession,
reviewLeasePath: (repositoryRoot: string, target: string) =>
`${repositoryRoot}/.qwen/tmp/qwen-review-lease-${target}.json`,
`${repositoryRoot}/.qwen/review-leases/qwen-review-lease-${target}.json`,
isReviewLeaseFile: (fileName: string) =>
/^qwen-review-lease-pr-\d+\.json$/.test(fileName),
}));
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/review/fetch-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ vi.mock('../../services/review-worktree-lease.js', () => ({
readReviewWorktreeLease: vi.fn((): unknown => null),
reviewLeaseHeldByAnotherSession: vi.fn((): boolean => false),
reviewLeasePath: (repositoryRoot: string, target: string) =>
`${repositoryRoot}/.qwen/tmp/qwen-review-lease-${target}.json`,
`${repositoryRoot}/.qwen/review-leases/qwen-review-lease-${target}.json`,
}));

vi.mock('./lib/gh.js', async (importOriginal) => {
Expand Down
43 changes: 41 additions & 2 deletions packages/cli/src/commands/review/fetch-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ import {
reviewLeaseHeldByAnotherSession,
reviewLeasePath,
} from '../../services/review-worktree-lease.js';
import { sanitizedGitEnv } from './lib/worktree.js';
import { sanitizedGitEnv, untrustedGitfile } from './lib/worktree.js';
import { mountRootFor } from './lib/sandboxed-exec.js';
import { setGhHost } from './lib/gh.js';
import { getPlatformReader } from './lib/platform/registry.js';
import type { ReviewPlatformReader } from './lib/platform/types.js';
Expand Down Expand Up @@ -683,11 +684,35 @@ function tryResume(
const markerResumes = marker.resumes.filter(
(r) => r.sessionId.toLowerCase() !== currentKey,
).length;
// Before reading anything through this tree's own pointer: `--resume`
// coexists with the sandbox on the very lane it polices, and the tree it
// reads is the one the previous run's containerized commands could write.
// `status` REFRESHES THE INDEX, so a planted `core.fsmonitor` runs on the
// host inside the very command that collects the ruling's evidence — the
// attack does not need the resume to succeed. See `untrustedGitfile`.
// REFUSE TO FRESH, not refuse to crash. Throwing here propagates out of
// `runFetchPr`, the enclosing catch rolls the lease back and re-throws, and
// `cleanStale` — the thing that would REMOVE the planted tree — is never
// reached. That contradicts what this file, the `--resume` describe and the
// docs all promise: the flag never fails a run that could start over. So
// this returns a refusal like every other one, the fresh path runs, and the
// planted tree is swept on the way.
if (untrustedGitfile(wt, mountRootFor) !== null) {
return {
resumed: false,
reason: 'worktree-untrusted',
priorFetchedSha: null,
};
}
// `--porcelain` prints nothing on a clean tree; a null (the command could
// not run) is treated as dirty. `--untracked-files=normal` explicitly, so
// a `status.showUntrackedFiles=no` tuning cannot hide residue that is not
// in the PR.
// in the PR. `core.fsmonitor` inert here too: the gate above proves the
// pointer, this proves the command cannot be turned into an execution even
// if some future path reaches it ungated.
const status = gitOpt(
'-c',
'core.fsmonitor=',
'-C',
wt,
'status',
Expand Down Expand Up @@ -973,6 +998,20 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
// 4. Create the ephemeral worktree.
try {
mkdirSync(dirname(wt), { recursive: true });
// The fresh path checks files out too, and resolves through the same
// pointer the resume path was gated on — but the pointer it resolves
// through is the LAUNCH directory's, not the new tree's: `git()` sets no
// cwd, so `worktree add` finds the repository from `process.cwd()`.
// Gating `wt` was a no-op — `cleanStale` above has just removed whatever
// stood there, and a tree that does not exist has no pointer to
// distrust. In the nested geometry `mountRootFor` itself documents, the
// outer review's containerized phase can rewrite exactly this one.
const freshUntrusted = untrustedGitfile(process.cwd(), mountRootFor);
if (freshUntrusted !== null) {
throw new Error(
Comment thread
wenshao marked this conversation as resolved.
Resolved
`refusing to create a review worktree: ${freshUntrusted}`,
);
}
Comment thread
wenshao marked this conversation as resolved.
Resolved
git('worktree', 'add', wt, ref);
} catch (err) {
tryRemove(() =>
Expand Down
19 changes: 18 additions & 1 deletion packages/cli/src/commands/review/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,28 @@ export function assertWritableOutPath(out: string): void {
}

export const REVIEW_TMP_DIR = join('.qwen', 'tmp');

/**
* Where worktree leases live — a SIBLING of the review temp dir, not a child.
*
* The leases are host-trusted state: `cleanupReviewWorktreeLeases` matches one
* by session ids alone and then force-removes whatever worktree and deletes
* whatever branch it names. `REVIEW_TMP_DIR` is the directory the review
* sandbox bind-mounts read-write, so keeping them there put that state inside
* the container's writable surface — and inside reach of an unsandboxed run
* too, which is how it stood before the sandbox existed. Reviewed code that
* can edit a lease can make another session's cleanup destroy the wrong tree,
* or plant one no session will ever sweep and wedge that PR on that machine.
*
* One directory over is the whole fix: nothing mounts it, and the lease
* lifecycle is unchanged.
*/
export const REVIEW_LEASE_DIR = join('.qwen', 'review-leases');
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
export const REVIEWS_DIR = join('.qwen', 'reviews');
export const REVIEW_CACHE_DIR = join('.qwen', 'review-cache');

/**
* Filename prefix for review-worktree lease files under `REVIEW_TMP_DIR`.
* Filename prefix for review-worktree lease files under `REVIEW_LEASE_DIR`.
* Lives here, not in `review-worktree-lease.ts`, because the review
* workflow's cleanup sweep deletes leases by glob — the sweep pattern and
* the lease writer must share one definition (the cleanup spec pins both).
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/review/lib/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export type ResumeRefusal =
| 'diff-unreadable' // the captured diff is gone or cannot be read
| 'diff-hash-mismatch' // the diff file changed since it was captured
| 'head-moved' // the PR head advanced — the once-per-review restart case
| 'resume-cap'; // this review has already resumed RESUME_MAX times
| 'resume-cap' // this review has already resumed RESUME_MAX times
| 'worktree-untrusted'; // the tree's gitfile no longer resolves to its own admin entry

export type ResumeAssessment =
| { ok: true }
Expand Down
Loading
Loading