Skip to content

fix: restoreConfigFromBase covers nested CLAUDE.md (#1270)#1271

Open
mahangu wants to merge 1 commit intoanthropics:mainfrom
mahangu:mahangu/fix-nested-claude-md-restore
Open

fix: restoreConfigFromBase covers nested CLAUDE.md (#1270)#1271
mahangu wants to merge 1 commit intoanthropics:mainfrom
mahangu:mahangu/fix-nested-claude-md-restore

Conversation

@mahangu
Copy link
Copy Markdown

@mahangu mahangu commented Apr 29, 2026

Fixes: #1270

Problem

restoreConfigFromBase reverts PR-controlled config from the trusted base before the Claude Code CLI starts. Its allowlist was path-literal and root-only:

const SENSITIVE_PATHS = [
  ".claude",
  ".mcp.json",
  ".claude.json",
  ".gitmodules",
  ".ripgreprc",
  "CLAUDE.md",
  "CLAUDE.local.md",
  ".husky",
];

Root CLAUDE.md was added in #1174 because PR-modified instruction files at startup are a prompt-injection surface. The same surface exists for nested CLAUDE.md files (Claude Code auto-loads them from cwd at any depth), but the literal list cannot match packages/foo/CLAUDE.md, apps/web/CLAUDE.md, etc.

Root cause

SENSITIVE_PATHS is iterated as literal paths against the working tree — there is no recursive discovery for basenames Claude Code auto-loads at depth.

Fix

A recursive pass that runs alongside the existing SENSITIVE_PATHS loop:

  1. Discovers nested CLAUDE.md / CLAUDE.local.md on PR head via git ls-files -z and on origin/<base> via git ls-tree -r -z --name-only origin/<base>, filtered to exact basename matches.
  2. Snapshots PR-authored files into .claude-pr/ for review-agent inspection (with the encoding defenses below).
  3. Deletes them from the working tree before git fetch.
  4. Restores from base via git checkout origin/<base> -- <path>.
  5. Unstages with git reset -- so the revert doesn't leak into later commits.
  6. Fails closed (re-throws) on git errors rather than silently falling through to a root-only restore.

Two defenses guard the snapshot itself, since .claude-pr/<path>/CLAUDE.md would otherwise be a nested CLAUDE.md under cwd:

  • Basename suffix. Files matching the auto-load basenames are renamed with a .pr-snapshot suffix — both at the top level (snapshotDest) and after the recursive copy (a suffixAutoLoadedBasenames walk catches files brought in by cpSync of .claude/).
  • Symlink skip. Symlinks are skipped at the top level (lstatSync short-circuit) and inside recursively-copied directories (cpSync filter). Without the second guard, a PR could plant .claude/hooks/leak.txt → /etc/passwd (file or dir symlink) and have a review agent silently follow it.

Every git invocation that takes a discovered path uses --. The discovery filter rejects absolute paths, .. segments, and NUL bytes (belt-and-braces; git enforces these on tracked paths). A leading - is intentionally not rejected — that would let an attacker hide -pkg/CLAUDE.md from the discovery + delete loop.

DISABLE_NESTED_CLAUDE_MD_RESTORE=true opts out and reverts to the prior root-only behavior (re-opens this vulnerability; included for emergency rollback only).

Testing

15 tests in test/restore-config.test.ts exercise restoreConfigFromBase against a real temporary git repo (bare remote + base/head branches):

  • Root CLAUDE.md / CLAUDE.local.md restore (regression)

  • Nested restore for files present on both PR + base, base-only, and PR-only

  • Basename-prefix non-match (docs/CLAUDE.md.notes is left alone)

  • Snapshot encoding: .pr-snapshot suffix at top level and inside recursively-copied .claude/, with verbatim CLAUDE.md basenames absent from .claude-pr/

  • Symlink defenses: top-level CLAUDE.md → secret, root SENSITIVE_PATHS entry → secret, and nested symlinks (.claude/hooks/leak.txt → file, .claude/leaky → dir) — assertions confirm no secret content reaches .claude-pr/

  • Leading-dash directory carve-out (-pkg/CLAUDE.md is found and deleted)

  • Opt-out flag (DISABLE_NESTED_CLAUDE_MD_RESTORE=true)

  • Restored paths are unstaged (no leak into auto-commits)

  • bun test passes (680/680)

  • bun run typecheck passes

  • bun run format:check passes

Related / supersedes

Closes anthropics#1270. Extends restoreConfigFromBase to revert PR-controlled
nested CLAUDE.md / CLAUDE.local.md files (auto-loaded by Claude Code
from cwd at any depth), not just the root entries in SENSITIVE_PATHS.

Discovery uses `git ls-files -z` on PR head and `git ls-tree -r -z`
on origin/<base>, filtered by basename. Discovery failures fail
closed (re-throw) rather than silently leaving nested PR-controlled
instruction files on disk. Opt out with
`DISABLE_NESTED_CLAUDE_MD_RESTORE=true` (re-enables the gap).

Snapshot defenses (so .claude-pr/ can't itself be loaded or used to
exfiltrate target file contents):
- Auto-load basenames are renamed with `.pr-snapshot` suffix at
  both the top level (`snapshotDest`) and after the recursive copy
  (`suffixAutoLoadedBasenames` walk catches files inside .claude/).
- Symlinks are skipped at the top level (lstatSync) and inside
  recursive directory copies (cpSync filter), so a PR can't point
  CLAUDE.md or .claude/hooks/leak.txt at /etc/passwd and have the
  target read through .claude-pr/.

Reviewed by claude-architect, superpowers code-reviewer, codex,
agent-skills security-auditor, unit-tests auditor, and a docs-
accuracy reviewer; each round caught a distinct class of bug
(symlink exfiltration, recursive snapshot survival, leading-dash
bypass, nested-symlink exfiltration). 15 tests cover root restore,
nested restore both-sides, base-only / PR-only nested, snapshot
encoding, leading-dash dirs, top-level + nested symlinks, and the
opt-out flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mahangu
Copy link
Copy Markdown
Author

mahangu commented Apr 29, 2026

Hi @ashwin-ant! 👋🏾

Given your work on the restoreConfigFromBase code path (you authored #1204 and merged the original root CLAUDE.md restore in #1174), would appreciate your eyes on this when you have a moment. The change extends the same trust-boundary pattern to nested CLAUDE.md files and incidentally covers #1186 / #1187 as noted in the PR description.

No rush — happy to iterate on anything that doesn't sit right. Thanks!

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.

restoreConfigFromBase should cover nested CLAUDE.md files

1 participant