Skip to content

fix(targets): guard writers against ancestor-symlink traversal on install#1100

Open
tmchow wants to merge 1 commit into
mainfrom
tmchow/writer-ancestor-symlink-containment
Open

fix(targets): guard writers against ancestor-symlink traversal on install#1100
tmchow wants to merge 1 commit into
mainfrom
tmchow/writer-ancestor-symlink-containment

Conversation

@tmchow

@tmchow tmchow commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to the leaf-node symlink preservation shipped in #1089 (Pi) and #1094 (Codex/OpenCode). Those guards inspect only the leaf path component (isPreservedSymlinklstat the final node). A user who repoints a whole managed store directory — or any ancestor of it — at a personal fork slips past them, because lstat/fs.rm/copy follow every ancestor symlink. The write then operates through the link, into the fork.

This was the "ancestor-symlink traversal" class disclosed as out-of-scope in #1094's scope notes. This PR closes it for the three writers that already have the leaf-ownership contract (codex, opencode, pi).

Concrete example: user runs ln -s ~/my-fork ~/.codex/skills/compound-engineering. Before this PR, install --to codex would fs.rm and re-copy skill dirs inside ~/my-fork, clobbering their fork. After: the store is detected as escaping the codex root, skipped wholesale, and recorded as owning nothing.

Fix

A realpath-based containment check in managed-artifacts.ts:

  • realpathNearestExisting(p) — realpath of the nearest existing ancestor (a not-yet-created descendant can't add a symlink hop, so this suffices and works before a fresh store exists).
  • isContainedPath(rootReal, targetReal) — pure predicate; now also reused by codex's existing isPathInside, removing a duplicated relative-path check.
  • isPathWithinRoot(root, target) — realpaths both sides (so a config root legitimately relocated as a whole still reads as contained) and applies isContainedPath.
  • storeRootEscapesManagedRoot(root, storeDir) — warns + returns true when a store escapes; the caller then skips every operation on that store.

Applied per writer against the target root (~/.codex, the OpenCode root, the Pi store parent):

  • codex.ts — guards the skills, agents, and prompts stores; escaped stores are excluded from the install manifest. The codex-local legacy-artifact mover and cleanupPreviousManagedCodexSkillStore (a recursive fs.rm) gain the same containment check at their destructive op.
  • opencode.ts — guards all four stores (agents, commands, plugins, skills), each of which has a manifest ownership record.
  • pi.ts — guards skills, prompts, extensions, agents; the pi-local legacy mover gains the containment check.
  • managed-artifacts.ts — the shared moveLegacyArtifactToBackup gains the containment guard at its fs.rename, gated by the existing skipSymlinkGuard escape hatch (so the one intentional symlink-relocator in the cleanup command is exempt). This one line covers the OpenCode/Kiro legacy sweeps and the standalone cleanup command's move paths.

Escaped stores use the same preserved/continue idiom #1094 established, so the manifest never claims a path the install didn't write.

Verification

  • 7 new tests (2 codex, 1 opencode, 1 pi, + the shared paths they exercise): a store dir that is itself a symlink into a fork, and a symlinked ancestor above the store dir (multi-level), both proving the fork content is untouched, the symlink node survives, and the manifest records nothing.
  • Red-green: with src/ stashed, all 4 new ancestor tests fail — the assertion exists(fork/<written-path>) is true (the fork gets written through) without the guard, false with it.
  • Full bun test: 1930 pass / 0 fail; existing fix(pi): preserve user-managed symlinks and unmanaged paths on install #1089/fix(targets): extend user-symlink preservation to codex and opencode writers #1094 leaf-preservation tests pass unmodified (leaf-symlink cases short-circuit at isPreservedSymlink before the new ancestor check). bun run release:validate in sync.

Scope decisions

…tall

The leaf-node symlink guards from #1089/#1094 inspect only the final path
component, so a user who repoints a whole managed store dir (or an ancestor
of it) at a personal fork -- e.g. `~/.codex/skills/<plugin>` -> fork -- still
had every cleanup/write on install operate THROUGH the link into the fork.

Add a realpath-based containment check (isPathWithinRoot /
storeRootEscapesManagedRoot in managed-artifacts.ts) and apply it in the
codex, opencode, and pi writers: a store that resolves outside the target
root is skipped wholesale -- cleanup sweeps and writes alike -- and recorded
as owning nothing in the install manifest. The shared and per-writer
legacy-artifact movers and codex's previous-skill-store rm gain the same
guard at their destructive ops. isPathInside (codex) now reuses the shared
isContainedPath predicate.

Antigravity is out of scope: it has no leaf-ownership contract at all, so
adding only ancestor-containment there would be a misleading half-guard; it
needs the #1089/#1094 leaf contract first.

Co-Authored-By: Claude Code <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.

1 participant