Tool policy — making read-only real¶
Problem¶
A "read-only agent" was not expressible in this runtime. The character's tools block
(HTTP endpoints) and mcpServers are additive on top of Claude Code's built-in
toolset — which includes Bash, Write, Edit — and the CLI was always spawned with
--dangerously-skip-permissions. A character declaring six read-only HTTP endpoints
still had a shell and a file writer, and a policy document saying writes: none
described an intent nothing enforced.
This matters most for an agent that reads text other people can write — issue titles, PR bodies, event payloads. Untrusted input reaching a model that holds a shell is the ordinary prompt-injection path, and "it's read-only" is exactly the claim that makes such an agent look safe to put behind a chat surface.
What the CLI actually does (verified, not assumed)¶
Two rounds of direct verification against the CLI shaped the design:
--allowedToolsis a permission pre-approval list, not a bound. With--allowedTools Read Grep Globthe init still reported 21 tools, includingWebFetch,Monitor,Skill,EnterWorktree,ToolSearch.- A deny list alone cannot be exhaustive. The first draft's deny list missed
Monitor— whose script parameter is a shell command, i.e. a fullBashbypass — plusSkill(executes skills, which run with the session's tools) andToolSearch(loads additional tools at runtime).
The mechanism that actually bounds the agent is the permission mode: without
--dangerously-skip-permissions, under --permission-mode dontAsk, any tool invocation
not pre-approved is auto-denied — including tools added in future CLI releases.
Design¶
src/agent/providers/tool-policy.js — buildToolPolicy(character.llm):
| Declaration | Effect |
|---|---|
| (none) | No flags. Existing agents are byte-for-byte unaffected. |
readOnly: true |
Drops --dangerously-skip-permissions; passes --permission-mode dontAsk; pre-approves Read, Grep, Glob, TodoWrite; denies every write-capable tool. |
allowedTools / disallowedTools |
Passed through as given; permission posture unchanged. |
Deny always wins:
- Under
readOnly, a declaredallowedToolsis intersected with the read-only set — a character cannot pre-approve itselfBashby listing it. - A declared
disallowedToolsis added to the deny list. Subtracting is always possible; adding never is.
WebFetch and WebSearch are denied under readOnly even though they cannot write to
this system: they can carry data out of it, and exfiltration is the other half of the
injection problem.
Configuration¶
Optional narrowing on top: "allowedTools": ["Read"], "disallowedTools": ["LSP"].
Limits, stated plainly¶
- This bounds the claude-cli provider's tool surface. It does not sandbox the
process: the character's own HTTP
toolsand any declared MCP servers are still reachable, as intended — those are the grant. openai-apiandanthropicenforcereadOnlyby construction (#668): the in-process loop's tool set is exactly[...buildTools(character), ...mcpClients.tools]— the declared HTTP endpoints plus the declared MCP servers, connected fromcharacter.mcpServersalone. No Bash, no Write, no built-ins. That is stricter than claude-cli's read-only mode, which still pre-approves Read/Grep/Glob. The write-capability of a declared MCP server remains the character author's responsibility, as it already is formcp__<name>on claude-cli.codex-cliandclaude-tmuxare not covered. AreadOnlycharacter is refused on them at boot and they are stripped from areadOnlyfallback chain.- Enforcement is by the CLI's permission layer, in-process. It is the right boundary for "the model should not have a shell"; it is not a substitute for pod-level isolation where that is warranted.