Skip to content

Auto-loaded CLAUDE.md reaches callers although ~/.claude is denied — the guard bounds tool calls, not context #416

Description

@KenTaniguchi-R

Found by asking a real inbound caller for an introduction to the owner and then checking where the answer came from.

What happened

A remote caller asked the owner's agent for an intro. It returned accurate personal and operational detail. The guard log for that call shows:

Bash allowed=true  ×3     (blocked downstream by --allowedTools, see #415)
Read allowed=true  ×1

One file read, no Skill invocation. One read cannot produce that answer.

The content matches ~/.claude/CLAUDE.md, which is denied by the scope:

DENIED   /Users/ryuseitaniguchi/.claude/CLAUDE.md
READABLE /Users/ryuseitaniguchi/.claude/skills/<skill>/SKILL.md

Claude Code loads memory files into the system prompt itself. There is no tool call, so guard.ts never sees them, and a path the model reports as unreachable reaches the caller anyway.

Why this is worse than the SKILL.md body bypass

The body bypass is already recorded in 2026-08-06-skill-and-mcp-guard-reachability.md, but only for skills. This is the same mechanism with a wider blast radius:

  • ~/.claude is explicitly on the denylist. The skills carve-out was a deliberate exception; CLAUDE.md was not carved out, so this is content the model actively refuses via Read and discloses anyway.
  • It transitively pulls in more. ~/.claude/CLAUDE.md here is 15 lines that @-import other files; those are resolved and injected too, and none of them pass the guard either.
  • It is where operational instructions live. A memory file is exactly where someone writes context about themselves and how they work.
  • Plugin- and MCP-injected context plausibly rides the same path. Not proven here, and worth checking separately — but nothing about the mechanism is specific to CLAUDE.md.

Scope of the claim

Proven: the file is denied by isReadable; exactly one Read occurred on that call; no skill ran; the reply contains material matching that file.

Not proven: the precise provenance of every sentence in the reply. That is #400's gap — there is no per-call record of what was read, so this had to be established by elimination rather than read off a log. That the argument needed elimination is itself an argument for #400.

What this means for the model

The guard bounds tool calls, not context. Everything Claude Code injects before the first tool call — memory files and their imports, and plausibly plugin/MCP context — is outside it by construction. The denylist cannot express "do not put this in the prompt", because nothing asks it.

Options, none evaluated:

  • Spawn with memory loading disabled where the runtime supports it, so the injected set is empty and the guard is the only path in.
  • Treat the memory files as part of the reply surface: accept that they leak, and say so in the docs rather than denying ~/.claude and implying otherwise.
  • Move the guarantee to the sink (#399) — a provenance check on the reply is the only shape that could bound context the guard never sees, though it still needs the injected set enumerated.

The one thing that should not stand is the current state: ~/.claude denied in the model, disclosed in practice, with nothing saying so.

Related

#415 (the Bash audit record, same call) · #399/#400 (sink-side provenance) · #413 (why this call needed a local-relay workaround)

Activity

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions