Skip to content

fix(security): reject symlink-mode blobs in the worktree patch (closes #15)#19

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

fix(security): reject symlink-mode blobs in the worktree patch (closes #15)#19
axisrow merged 1 commit into
mainfrom
sec-patch-symlink-guard

Conversation

@axisrow

@axisrow axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Closes #15 (HIGH, security review of #12).

Vulnerability

applyWorktreePatch captures the worktree diff (git diff --cached --binary --output=<file>) and replays it in repoRoot (git apply --index). A symlink created in the worktree — ln -s ~/.ssh/authorized_keys ./steal — is captured as a mode-120000 diff entry with the target path verbatim. git apply --index recreates it in repoRoot pointing at the attacker-chosen host path → exfil (cat/git-show/editor) and append (>>) primitive.

The byte-preserving --output transfer does not help — the symlink target is legitimately part of the diff. git apply rejects literal ../ filename paths, but symlink-mode entries bypass that guard (the repo-side filename is normal; only the symlink target points outside).

Attack reproduced before the fix: ln -s /etc/passwd ./steal in the worktree produced a patch with new file mode 120000, and git apply --index recreated <repoRoot>/steal -> /etc/passwd.

Fix

After writing the patch file, scan it for a mode 120000 line. If found, do NOT apply — return {applied:false} directing the user to inspect/copy symlinks manually from the worktree. Leave-branch preserved (worktree stays; nothing destroyed).

patchContent = readFileSync(patchPath)
if /\bmode 120000\b/.test(patchContent) → {applied:false, detail:"...symlinks...inspect manually"}

Verified

  • Regression test: symlink in worktree → keep → applied:false, symlink NOT in repoRoot.
  • Pre-fix attack confirmed (git apply recreated the symlink).
  • worktree.test.mjs 14/14; full npm test 157 pass / 4 pre-existing (unchanged).

Severity note (from #15): code-path-severity-once-wired — no production callers today; closes the hole before companion --worktree wiring.

Closes #15 (HIGH). applyWorktreePatch captures the worktree diff with
`git diff --cached --binary --output=<file>` and replays it with
`git apply --index` in repoRoot. A symlink created in the (attacker/Codex-
controlled) worktree — `ln -s ~/.ssh/authorized_keys ./steal` — is captured as
a mode-120000 diff entry with the target path verbatim. `git apply --index`
then recreates the symlink in repoRoot pointing at the attacker-chosen absolute
host path. Subsequent cat/git-show/editor-open exfiltrates the file; >> appends.

The byte-preserving `--output` transfer does not help — the symlink target is
legitimately part of the diff. `git apply` rejects literal "../" filename paths,
but symlink-mode entries bypass that guard: the repo-side filename is a normal
path; only the symlink target points outside.

Attack reproduced before the fix: `ln -s /etc/passwd ./steal` in the worktree
produced a patch with `new file mode 120000`, and `git apply --index` recreated
`<repoRoot>/steal -> /etc/passwd`.

Fix: after writing the patch file, scan it for a `mode 120000` line. If found,
do NOT apply — return {applied:false} with a message directing the user to
inspect/copy the symlinks manually from the worktree. The leave-branch invariant
is preserved (the worktree stays; nothing is destroyed).

Regression test: plant a symlink in the worktree, call keep, assert applied
=== false and the symlink is NOT recreated in repoRoot.

Verified: worktree 14/14; full npm test 157 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 force-pushed the sec-patch-symlink-guard branch from cc1bda4 to a6658d8 Compare July 19, 2026 18:55
@axisrow

axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1) — security

Reviewer Verdict Finding Status
claude CLAIM HOLDS regex catches all symlink-mode variants (empirically tested); git apply creates symlink IFF patch has 'mode 120000'; suppression of marker = no symlink replay. No bypass. confirmed
claude (LOW) FP on tracked files documenting git modes regex /\bmode 120000\b/ matched literal content tightened to line-anchored /^(?:new file|deleted file|old|new) mode 120000$/m + FP regression test (a6658d8)
codex (truncated earlier; non-blocking)
claude (INFO) TOCTOU on patchPath non-exploitable (swapped patch without marker can't create symlink; attacker already owns repoRoot in that scenario) not a finding

Triage: 0 FIX in scope, 1 LOW applied (regex tightening). #15 closed. 15/15 worktree tests; full npm test 158 pass / 4 pre-existing. Local mode — merge on user trigger.

@axisrow
axisrow merged commit 011ccc0 into main Jul 19, 2026
@axisrow
axisrow deleted the sec-patch-symlink-guard branch July 19, 2026 18:58
axisrow added a commit that referenced this pull request Jul 20, 2026
Redesign after Codex+Claude review found the two-phase --raw detection in the
prior commit was TOCTOU-exploitable: patch was generated from the mutable index,
then a separate --raw check read the index again — a background process (a
compromised Codex run that can write to the worktree) mutated the index between
the two calls, so the patch carried a symlink while --raw reported clean, and
git apply replayed the symlink into repoRoot. Demonstrated at ~15% reliability
with a plain bash busy-poll. In-scope for #15's threat model (the worktree IS
the Codex workspace; Codex has write access and can spawn processes).

Fix: freeze the staged index into an IMMUTABLE tree via git write-tree, then
derive BOTH the patch and the symlink verdict from <baseCommit>..<tree> — never
from the mutable index. The two reads now reference the same immutable object, so
no window exists between them. Also add --no-ext-diff/--no-textconv so a poisoned
diff.external driver (writable via the linked-worktree shared commondir) cannot
affect patch generation.

Symlink detection: git diff-tree -r --raw on the frozen tree reports modes for
every changed entry; reject if either old or new mode column is 120000. Catches
new/retargeted/mode-changed/renamed symlinks uniformly; not subject to
diff.external/.gitattributes (structural mode data, not rendered text).

Leave-branch invariant preserved (worktree stays on refusal).

Regression tests (3, all green):
- existing tracked symlink retargeted → applied:false (the #19 bypass).
- new symlink (mode 120000) → caught.
- regular file whose content mentions "mode 120000" → applied (no false positive).

Verified: worktree 16/16; full npm test 159 pass / 4 pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189cGaohsYdpkB4otKZnpAt
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(high): symlink-mode blob in worktree patch replays a host-file symlink into repoRoot

1 participant