Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
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
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 @@ -92,7 +92,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 @@ -330,7 +330,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
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.
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as 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
155 changes: 154 additions & 1 deletion packages/cli/src/commands/review/lib/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ import {
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { basename, dirname, join, relative, sep } from 'node:path';
import { isolateHostGitConfig } from './test-utils.js';
import {
adminEntryInsideReviewTmp,
untrustedGitfile,
discardWorktree,
exposeDependencies,
sanitizedGitEnv,
Expand Down Expand Up @@ -1872,3 +1874,154 @@ describe('worktreeCreateFailureDetail', () => {
);
});
});

describe('untrustedGitfile', () => {
Comment thread
wenshao marked this conversation as resolved.
const made: string[] = [];
const tmp = () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'qwen-gitfile-')));
made.push(dir);
return dir;
};
afterEach(() => {
for (const dir of made.splice(0))
rmSync(dir, { recursive: true, force: true });
});

/** The pipeline's own shape: a gitfile naming an entry under `.git/worktrees`. */
const pipelineTree = (repo: string) => {
const tree = join(repo, '.qwen', 'tmp', 'review-pr-1');
const entry = join(repo, '.git', 'worktrees', 'review-pr-1');
mkdirSync(tree, { recursive: true });
mkdirSync(entry, { recursive: true });
writeFileSync(join(tree, '.git'), `gitdir: ${entry}\n`);
return { tree, entry, mount: () => join(repo, '.qwen', 'tmp') };
};

it('ADMITS an intact pipeline gitfile, relative spelling included', () => {
// The admit path, which nothing exercised: every refusal case here would
// also refuse under a mutation that mangles the parsed target — realpath
// fails, the location check fails closed, and the expected refusal still
// happens — while in production the same mangling refuses every healthy
// tree. Only asserting the ADMIT distinguishes the two.
const repo = tmp();
const { tree, entry, mount } = pipelineTree(repo);
expect(untrustedGitfile(tree, mount)).toBeNull();

// Git writes this pointer relative when it can, so the parse has to
// resolve against the TREE, not the process cwd.
writeFileSync(
join(tree, '.git'),
`gitdir: ${relative(tree, entry)}\n`.split(sep).join('/'),
);
expect(untrustedGitfile(tree, mount)).toBeNull();
});

it('refuses the two shapes reviewed code can write', () => {
const repo = tmp();
const { tree, mount } = pipelineTree(repo);
// A pointer into the mount...
const planted = join(repo, '.qwen', 'tmp', '.evil-git');
mkdirSync(planted, { recursive: true });
writeFileSync(join(tree, '.git'), `gitdir: ${planted}\n`);
expect(untrustedGitfile(tree, mount)).toContain('review temp dir');

// ...and `.git` replaced by a directory, which skips every gate written
// for the gitfile shape.
rmSync(join(tree, '.git'), { force: true });
mkdirSync(join(tree, '.git'));
expect(untrustedGitfile(tree, mount)).toContain('not the gitfile');
});

it('says nothing at all outside a mount', () => {
// A plain checkout's `.git` IS a directory; refusing that would refuse
// every ordinary repository.
const repo = tmp();
mkdirSync(join(repo, '.git'));
expect(untrustedGitfile(repo, () => null)).toBeNull();
});
});

describe('adminEntryInsideReviewTmp', () => {
const made: string[] = [];
const tmp = () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'qwen-admin-')));
made.push(dir);
return dir;
};
afterEach(() => {
for (const dir of made.splice(0))
rmSync(dir, { recursive: true, force: true });
});

it('refuses an entry planted inside the mounted directory', () => {
// The shape the round-trip gate cannot see: the writer chose BOTH the
// gitfile's target and that target's backpointer, so the two agree, no
// symlink is involved, and `--show-toplevel` still names the tree. Only
// the entry's location tells them apart.
const repo = tmp();
const tree = join(repo, '.qwen', 'tmp', 'review-pr-1-probe');
const planted = join(repo, '.qwen', 'tmp', '.evil-git');
mkdirSync(tree, { recursive: true });
mkdirSync(planted, { recursive: true });
expect(
adminEntryInsideReviewTmp(
planted,
() => join(repo, '.qwen', 'tmp'),
tree,
),
).toBe(true);
});

it('counts the mount root itself, and a child that looks like an escape', () => {
// The two shapes a hand-rolled prefix test gets wrong, and both are places
// a planted entry can actually sit. The mount ROOT is as writable as
// anything under it, and `..evil-git` is a legal filename whose relative
// path starts with the characters an escape would.
const repo = tmp();
const mount = join(repo, '.qwen', 'tmp');
const tree = join(mount, 'review-pr-1-probe');
const oddly = join(mount, '..evil-git');
mkdirSync(tree, { recursive: true });
mkdirSync(oddly, { recursive: true });
expect(adminEntryInsideReviewTmp(mount, () => mount, tree)).toBe(true);
expect(adminEntryInsideReviewTmp(oddly, () => mount, tree)).toBe(true);
// ...while a genuine sibling of the mount is still outside.
const outside = join(repo, '.qwen', 'review-leases');
mkdirSync(outside, { recursive: true });
expect(adminEntryInsideReviewTmp(outside, () => mount, tree)).toBe(false);
});

it('admits the real admin entry, which lives under the repository git dir', () => {
const repo = tmp();
const tree = join(repo, '.qwen', 'tmp', 'review-pr-1-probe');
const real = join(repo, '.git', 'worktrees', 'review-pr-1-probe');
mkdirSync(tree, { recursive: true });
mkdirSync(real, { recursive: true });
expect(
adminEntryInsideReviewTmp(real, () => join(repo, '.qwen', 'tmp'), tree),
).toBe(false);
});

it('refuses rather than guesses when the entry cannot be resolved', () => {
// Fails CLOSED: "not inside" would be a guess, and the guess that lets a
// planted entry through is the one that ends in host execution.
const repo = tmp();
const tree = join(repo, '.qwen', 'tmp', 'review-pr-1-probe');
mkdirSync(tree, { recursive: true });
expect(
adminEntryInsideReviewTmp(
join(repo, 'gone'),
() => join(repo, '.qwen', 'tmp'),
tree,
),
).toBe(true);
});

it('has nothing to say about a tree outside any mounted directory', () => {
// A local checkout is never mounted, so there is no writable surface for
// this question to be about — and answering `true` there would refuse
// every ordinary repository.
const repo = tmp();
expect(adminEntryInsideReviewTmp(repo, () => null, repo)).toBe(false);
});
});
110 changes: 110 additions & 0 deletions packages/cli/src/commands/review/lib/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
import { isSubpath } from '@qwen-code/qwen-code-core';
import { readWorkspacePackages } from './workspaces.js';

export type SweepResult = ReturnType<typeof spawnSync>;
Expand Down Expand Up @@ -109,6 +110,115 @@ const GIT_ENV_EXEC = [
'XDG_CONFIG_HOME',
];

/**
* Why a tree's own gitfile cannot be trusted to resolve a host-side git write.
*
* Returns a refusal, or null when there is nothing to police. Every host-side
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
* git command that WRITES resolves the repository through the `.git` of the
* tree it runs in, and for every tree this pipeline builds that `.git` sits
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
* inside the directory the sandbox mounts read-write. Two shapes reach a
Comment thread
wenshao marked this conversation as resolved.
Outdated
* planted repository from there, and both end in a `filter.<x>.smudge`
* running on the host:
*
* - the gitfile rewritten to an admin entry planted under the same mount, and
* - the gitfile REPLACED by a `.git` directory, which is a repository of the
* writer's own and skips every identity gate written for the gitfile shape.
*
* Neither is a shape the pipeline creates under the mount — its trees come
* from `git worktree add`, which writes a gitfile pointing at
* `<repo>/.git/worktrees/` — so refusing both costs nothing. Outside a mount
* this says nothing at all: a plain checkout's `.git` IS a directory, and
* refusing that would refuse every ordinary repository.
*/
export function untrustedGitfile(
tree: string,
// Required, not defaulted to `mountRootFor`: importing it here would close
// an ESM cycle — `sandboxed-exec` already imports `redirectedAncestor` from
// this module — and a cycle whose only symptom appears at module-init time
// is not worth a saved argument.
mountRoot: (cwd: string) => string | null,
): string | null {
const root = mountRoot(tree);
if (root === null) return null;
Comment thread
wenshao marked this conversation as resolved.
Outdated
const dotGit = join(tree, '.git');
let stat;
try {
stat = lstatSync(dotGit);
} catch {
return `${tree} has no .git to resolve`;
}
if (!stat.isFile()) {
return `${tree}'s .git is not the gitfile the pipeline created, and the tree is inside the review temp dir`;
}
let target: string;
try {
const raw = readFileSync(dotGit, 'utf8').trim();
if (!raw.startsWith('gitdir:')) {
return `${tree}'s .git does not name an admin entry`;
}
target = resolve(tree, raw.slice('gitdir:'.length).trim());
Comment thread
wenshao marked this conversation as resolved.
Outdated
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
if (adminEntryInsideReviewTmp(target, mountRoot, tree)) {
return `${tree}'s admin entry is inside the review temp dir, where the reviewed code can rewrite it`;
}
return null;
Comment thread
wenshao marked this conversation as resolved.
Outdated
}

/**
* Whether a linked worktree's admin entry sits where reviewed code can write it.
*
* The gitfile inside a pipeline tree cannot move — git requires `<tree>/.git` —
* and that tree is inside the directory the sandbox bind-mounts read-write, so
* containerized code (and, before the sandbox, any code the review ran) can
* rewrite it to `gitdir: <a directory it planted under the same mount>`. Put a
* `config` carrying `filter.<x>.smudge` in that directory and the next
* HOST-side `git checkout` executes it — out of the container, as the review
* user, on CI with the runner's tokens.
*
* The identity gates that already exist do not catch this, and cannot: the
* planted directory's own backpointer round-trips because the same writer
* chose it, `--show-toplevel` still prints the tree the gitfile sits in, and
* no symlink is involved anywhere. Asking git for the common dir does not help
* either — that answer is resolved THROUGH the rewritten gitfile, so it comes
* from the same hand.
*
* The question that does have an honest answer is about location, not
* content: a real linked worktree's admin entry lives under
* `<repo>/.git/worktrees/`, which is outside the mounted directory; a planted
* one has to be inside it, because that is the only place the writer can
* reach. So refuse an admin entry that resolves inside the review temp dir,
* and let every other check stand as it is.
*/
export function adminEntryInsideReviewTmp(
gitDir: string,
mountRoot: (cwd: string) => string | null,
tree: string,
): boolean {
const root = mountRoot(tree);
if (root === null) return false;
Comment thread
wenshao marked this conversation as resolved.
Outdated
let real: string;
let realRoot: string;
try {
real = realpathSync(resolve(gitDir));
realRoot = realpathSync(root);
} catch {
// Unresolvable is not a licence to proceed: the caller's other gates
// report it, and answering "not inside" here would be a guess.
return true;
}
// `isSubpath`, not a hand-rolled prefix test. The first cut was hand-rolled
// and failed open at both boundaries of the question it was asking: an entry
// whose realpath IS the mount root produced an empty relative path and was
// read as "outside" — while the root is exactly as writable as anything
// under it — and a direct child legitimately named `..evil-git` produced a
// relative path starting with `..`, which the prefix test read as an escape.
// Both are places a planted entry can sit, and both are why path containment
// belongs in one tested helper rather than in each caller's arithmetic.
return isSubpath(realRoot, real);
Comment thread
wenshao marked this conversation as resolved.
Outdated
}

/**
* The first symlink at or above `dir`, or null when every component is real.
*
Expand Down
Loading
Loading