Blog · · by Rafael Asor, Attenu
A receipt for what Claude Code gave the subagent
What Claude Code built well. Of every agent framework we have integrated, Claude Code has the most complete
subagent permission model, and it ships in the product rather than in a design note. A subagent is a markdown
file with frontmatter. tools: is an allowlist — "If tools is specified, the subagent receives only those
tools". disallowedTools: is a denylist, and when both are set the
docs are precise about the order: "disallowedTools is applied first… then tools is resolved against the
remaining pool", with a tool named in both removed. MCP servers scope to a single subagent. Agent(worker,
researcher) restricts which subagents may be spawned at all. permissionMode inherits, with the stricter parent
winning. And hooks run inside subagents, not just on the main thread, so
the enforcement point reaches the place the work actually happens. Nothing in this post is a gap in that.
The one thing left open. Two things, and both are about the paperwork rather than the enforcement. First, a
person writes each of those lists by hand. There is no field that computes tools: from what the subagent's job
actually is, so the list is a second copy of an intention that already exists in the project — and second copies
drift. Second, when a call is refused, the record is a transcript. Subagent transcripts live at
projects/{project}/{sessionId}/subagents/agent-{agentId}.jsonl and they are genuinely useful. They are also
plain JSONL: the docs describe no chain, signature, or anchor over them. If an auditor asks "show me every action
this agent was allowed to take, and prove nothing was removed from the list", a transcript cannot answer on its
own. That is not what transcripts are for. It is just the question we kept being asked.
The code. A PreToolUse hook, registered with no matcher so it runs for every tool call:
{"hooks": {"PreToolUse": [{"hooks": [
{"type": "command", "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/attenu_hook.py"}]}]}}
The hook reads the project's own declared structure — .claude/agents/*.md and the permissions block in
.claude/settings.json — and turns it into an Authority per agent, each one meet(session, derived), so a
subagent can never hold more than the session. Nobody writes a tool list twice, because there is only one list.
Every decision goes to a hash-chained ledger under .attenu/. Each hook invocation is its own process, so the
ledger is reloaded, re-verified and appended under a lock on every call.
The denial. A reviewer subagent declaring tools: Read, Grep, Glob, driven with the exact JSON Claude Code
sends:
reviewer Read allowed within its derived permissions
reviewer Write DENIED Write needs fs.write, not in the set derived for 'reviewer' (unresolved)
reviewer Bash DENIED Bash needs exec.bash, not in the set derived for 'reviewer' (unresolved)
reviewer WebFetch DENIED WebFetch needs net.fetch, not in the set derived for 'reviewer' (out_of_authority)
researcher WebFetch allowed within its derived permissions
tool bodies that actually ran: ['Read', 'WebFetch'] # unguarded control: all five ran
Claude Code's own allowlist would refuse the first three too — that is the point. The hook is the second check,
and what it adds is the last line and the two labels. out_of_authority means the project declares that
capability, just not for this agent. unresolved means nothing declares it, so it is denied by default. One is a
policy question for a person; the other is a default. A reviewer can act on the difference.
The verify. attenu_guard.evidence.verify_bundle(bundle, signer) checks three things from the bundle alone —
the chain reproduces and matches a signed anchor, every subagent's permissions are within its parent's, and every
allowed action was inside what that node held. No Claude Code, no our service, no network.
What remains Claude Code's. The allowlist, the denylist and their resolution order; Agent(name); MCP
scoping; permission modes; argument-scoped rules like Bash(npm run lint), which this recipe deliberately does
not restate because reading it as a bare Bash grant would widen it; workspace trust; managed settings; and the
hook system the whole thing stands on. The recipe reads what those declare and writes down what was decided.
Recipe, tests and the offline demo: https://github.com/attenu-io/attenu-guard/tree/main/examples/integrations/claude_code/hooks_receipt · https://attenu.io
Rafael Asor is the founder of Attenu and the maintainer of attenu-guard and attenu-derive, open-source Python libraries for AI agent permissions across sub-agent handoffs. He is the author of the IETF Internet-Draft draft-asor-wimse-agent-delegation-chain and is based in Tel Aviv.