Skip to content

security: worktree isolation (fork#12) — symlink traversal, patch symlink-replay, unbounded accumulation #13

Description

@axisrow

Security review of the worktree-isolation library layer merged in #12 (commit 1a9eb11). Found by an adversarial security pass (Codex + a second reviewer) against the threat model "a malicious or compromised Codex run, or a crafted repository, escapes the worktree sandbox / damages the host." The most serious findings were reproduced empirically, not theorized.

Context for severity: createWorktreeSession / cleanupWorktreeSession currently have no production callers (the library layer landed in #12; companion --worktree wiring is the next PR). So these are code-path severity once wired, not a live exploitable surface today. They must be closed before the --worktree flag reaches the CLI.

Findings

1. CRITICAL — .worktrees/ symlink traversal redirects worktree creation to an arbitrary host location

git.mjs createWorktree does fs.mkdirSync(path.join(repoRoot, ".worktrees"), { recursive: true }). Node's recursive mkdir follows a symlink at the final component. A crafted repo (or a co-resident user, or a prior malicious run that did rm -rf .worktrees && ln -s ~/.config .worktrees) causes the subsequent git worktree add to populate ~/.config/codex-<ts>/ — i.e. the Codex workspace-write root lands in ~/.ssh, ~/.config, ~/Library/LaunchAgents, etc. Reproduced end-to-end (.worktrees → /tmp/attacker-dest created /tmp/attacker-dest/codex-FAKE/; also the ~/.ssh variant).

2. HIGH — symlink-mode blob in the patch replays a host-file symlink into repoRoot

applyWorktreePatch captures the worktree diff with git diff --cached --binary --output and replays it with git apply --index in repoRoot. A symlink created in the worktree (ln -s ~/.ssh/authorized_keys ./steal) is captured as a mode-120000 entry with the target path verbatim, and git apply recreates it in repoRoot pointing at the host file. Subsequent cat/git show/editor-open exfiltrates; >> appends. The byte-preserving --output transfer does not help — the symlink target is legitimately part of the diff. Reproduced.

3. MEDIUM — git rev-parse --git-dir outside repoRoot → info/exclude append at an attacker-chosen path

createWorktree resolves git rev-parse --git-dir with path.resolve(repoRoot, rawGitDir). When --git-dir returns an absolute path (separate-git-dir, linked worktree, .git gitfile), path.resolve discards repoRoot and the subsequent appendFileSync(<gitDir>/info/exclude, ".worktrees/\n") writes to that absolute location. Content is benign (.worktrees/), but the missing boundary check is a structural gap. Partially reproduced (legit linked worktree); destructive chain needs a pre-staged valid git dir.

4. MEDIUM — unbounded worktree accumulation (disk DoS)

By design (leave-branch, #12) cleanupWorktreeSession never removes the worktree or branch. Every rescue call adds one .worktrees/codex-<ts>/ checkout + branch, forever. No cap, no roster, no warning. A rescue loop or long session fills disk (GB-per-call on a large repo).

Ruled out (checked, not exploitable)

  • Command injection — all git calls go through runCommand(..., {shell:false}) arg-array.
  • baseCommit / branch — plugin-generated (rev-parse HEAD SHA; codex/<timestamp>).
  • git apply path traversal via ../ in diff paths — git rejects even with --unsafe-paths (not passed).

Proposed fixes (for the security PR)

  1. lstat-guard .worktrees/ before mkdirSync (and reject symlinked components up the path) — closes Add model and effort flags to review commands #1. Single-line core.
  2. Reject mode-120000 (symlink) entries in the captured patch before git apply — closes Supersedes #408: thread model + effort overrides through app-server spawn path (fixes #476) #2.
  3. After path.resolve(gitDir), assert gitDir is contained in repoRoot (realpath-normalized), bail otherwise — closes Rebase fork on upstream #471 (RPC override) + cherry-pick shutdown hardening + #506/#480/#490 #3.
  4. At session creation, list .worktrees/codex-*; warn loudly at N (e.g. 10) or fail-closed — closes Rebase fork on upstream #471 (RPC override) + #506/#480/#490 + shutdown hardening #4. Additive, preserves the leave-branch contract.

Refs #12, fork#10, openai#135, openai#137.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions