Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions plugins/codex/scripts/lib/git.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,24 @@ function buildAdversarialCollectionGuidance(options = {}) {
export function createWorktree(repoRoot) {
const ts = Date.now();
const worktreesDir = path.join(repoRoot, ".worktrees");
// SECURITY (#14): if `.worktrees` already exists as a symlink, Node's recursive
// mkdirSync follows it and `git worktree add` would populate the symlink TARGET —
// a crafted repo (or a prior malicious run that did `ln -s ~/.config .worktrees`)
// would redirect the Codex workspace-write root into ~/.ssh, ~/.config, etc.
// Refuse any symlink at .worktrees, and verify the resolved path stays inside the repo.
if (fs.existsSync(worktreesDir) && fs.lstatSync(worktreesDir).isSymbolicLink()) {
throw new Error(
`Refusing to create worktree: ${worktreesDir} is a symlink (possible sandbox-escape attempt). Remove it manually if expected.`
);
}
fs.mkdirSync(worktreesDir, { recursive: true });
const realRepoRoot = fs.realpathSync(repoRoot);
const realWorktreesDir = fs.realpathSync(worktreesDir);
if (realWorktreesDir !== realRepoRoot && !realWorktreesDir.startsWith(realRepoRoot + path.sep)) {
throw new Error(
`Refusing to create worktree: ${worktreesDir} resolves outside the repository (to ${realWorktreesDir}).`
);
}

// Ensure .worktrees/ is excluded from the target repo without modifying tracked files.
// Use git rev-parse to resolve the real git dir (handles linked worktrees where .git is a file).
Expand Down
25 changes: 25 additions & 0 deletions tests/worktree.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,31 @@ test("renderWorktreeTaskResult renders the manual-remove instructions (no destru
}
});

// SECURITY regression (#14): a symlink at `.worktrees` must NOT be followed.
// Without the guard, recursive mkdirSync follows the symlink and `git worktree add`
// populates the symlink target — a crafted repo (or a prior malicious run) could
// redirect the Codex workspace-write root into ~/.ssh / ~/.config / etc.
test("createWorktreeSession refuses a symlink at .worktrees (no host-location redirect)", () => {
const { repoRoot } = createRepoWithInitialCommit();
const attackerDest = makeTempDir("worktree-attack-dest-");

// Plant the hostile symlink: .worktrees -> attackerDest (outside repoRoot).
fs.symlinkSync(attackerDest, path.join(repoRoot, ".worktrees"));

assert.throws(
() => createWorktreeSession(repoRoot),
/symlink/i,
"must refuse when .worktrees is a symlink"
);

// The attacker's destination must NOT have been populated.
assert.deepEqual(
fs.readdirSync(attackerDest),
[],
"attacker destination must remain empty (no worktree written through the symlink)"
);
});

// Regression: rescue changes are STAGED (getWorktreeDiff/applyWorktreePatch run
// git add -A). The rendered inspection command must be HEAD/base-based so it
// shows staged work — plain `git diff` shows only unstaged and could read empty,
Expand Down