attenu

Blog · · by , 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.