Skip to content

fix(security): refuse symlink at .worktrees before creating a worktree (closes #14)#18

Merged
axisrow merged 1 commit into
mainfrom
sec-worktree-symlink-guard
Jul 19, 2026
Merged

fix(security): refuse symlink at .worktrees before creating a worktree (closes #14)#18
axisrow merged 1 commit into
mainfrom
sec-worktree-symlink-guard

Conversation

@axisrow

@axisrow axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Closes #14 (CRITICAL, security review of #12).

Vulnerability

createWorktree did fs.mkdirSync(<repoRoot>/.worktrees, {recursive:true}). Node's recursive mkdir follows a symlink at the final component. A crafted repo, a co-resident user, or a prior malicious run that did ln -s ~/.config .worktrees redirects the subsequent git worktree add — the Codex workspace-write root — into ~/.ssh, ~/.config, ~/Library/LaunchAgents, etc. Sandbox escape + persistence/credential-injection primitive.

Attack reproduced end-to-end before the fix: .worktrees -> /tmp/attacker-dest caused git worktree add to populate /tmp/attacker-dest/codex-<ts>/ with HEAD content.

Fix

Before mkdirSync, lstat .worktrees and throw if it is a symlink; after creation, verify the resolved realpath is contained within repoRoot. The leave-branch invariant is preserved (no new Destroy path — this guards creation only).

if exists(.worktrees) and lstat(.worktrees).isSymbolicLink → throw
mkdirSync(.worktrees)
if realpath(.worktrees) not inside realpath(repoRoot) → throw

Verified

  • Regression test: plant a hostile symlink .worktrees -> <tmp outside repoRoot>, assert createWorktreeSession throws and the attacker destination stays empty.
  • Pre-fix attack confirmed (mkdirSync follows the symlink, git worktree add populates the target).
  • worktree.test.mjs 13/13; full npm test 156 pass / 4 pre-existing (unchanged).

Severity note (from #14): code-path-severity-once-wired — createWorktreeSession has no production callers today (#12 was the library layer); this closes the hole before the companion --worktree wiring lands.

Closes #14 (CRITICAL). createWorktree did fs.mkdirSync(<repoRoot>/.worktrees,
{recursive:true}) — Node's recursive mkdir follows a symlink at the final
component. A crafted repo, a co-resident user, or a prior malicious run that
did `ln -s ~/.config .worktrees` would redirect the subsequent `git worktree
add` (the Codex workspace-write root) into ~/.ssh / ~/.config /
~/Library/LaunchAgents — a sandbox escape + persistence/credential primitive.

Attack reproduced end-to-end before the fix: `.worktrees -> /tmp/attacker-dest`
caused `git worktree add` to populate /tmp/attacker-dest/codex-<ts>/ with HEAD
content.

Fix: before mkdirSync, lstat .worktrees and throw if it is a symlink; after
creation, verify the resolved realpath is contained within repoRoot. The
leave-branch invariant is preserved (no new Destroy path — this guards creation).

Regression test: plant a hostile symlink `.worktrees -> <tmp outside repoRoot>`,
assert createWorktreeSession throws and the attacker destination stays empty.

Verified: worktree 13/13; full npm test 156 pass / 4 pre-existing (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189cGaohsYdpkB4otKZnpAt
@axisrow

axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1) — security

Reviewer Verdict Finding
claude APPROVE Guard sound vs #14 threat model (static pre-planted symlink). Containment check uses separator () — prefix confusion closed. No false refusals on legit symlinked repoRoots. 13/13 tests.
codex (truncated, exit 1) Verdict needs-attention but 0 concrete findings — response cut off mid-analysis. Non-blocking.
claude (residual, out-of-scope) MEDIUM/notes Residual TOCTOU between realpath-check and — real but OUT of #14's stated threat model (static symlink only); 50 external race-attempts → 0 bypass; cleanly closed by the issue's own 'Better' suggestion (app-owned mkdtemp) in a follow-up, not this PR.

Triage: 0 FIX in scope. #14 closed as scoped. Local mode — merge on user trigger.

@axisrow
axisrow merged commit 5a19596 into main Jul 19, 2026
@axisrow
axisrow deleted the sec-worktree-symlink-guard branch July 19, 2026 18:55
axisrow added a commit that referenced this pull request Jul 20, 2026
Security hardening release: leave-branch worktree cleanup (#12) + 4 security
fixes (#18 symlink guard, #20 core.symlinks=false neutralize, #22 eliminate
info/exclude write, #23 accumulation warn). rescue default --background (#8).
test-teardown (#5). All verified with cycle-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(critical): .worktrees/ symlink traversal redirects worktree creation to arbitrary host location

1 participant