Skip to content

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

Closed

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.

Activity

  1. axisrow commented on Jul 19, 2026

    @axisrow
    OwnerAuthor

    Split into individual issues for tracking:

    All four are code-path-severity-once-wired (createWorktreeSession has no production callers today; #12 landed the library layer only). Closing this umbrella in favor of the per-finding issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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