Skip to content

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:

  1. --allowedTools is a permission pre-approval list, not a bound. With --allowedTools Read Grep Glob the init still reported 21 tools, including WebFetch, Monitor, Skill, EnterWorktree, ToolSearch.
  2. 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 full Bash bypass — plus Skill (executes skills, which run with the session's tools) and ToolSearch (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.jsbuildToolPolicy(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 declared allowedTools is intersected with the read-only set — a character cannot pre-approve itself Bash by listing it.
  • A declared disallowedTools is 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

"llm": {
  "provider": "claude-cli",
  "model": "claude-opus-4-8",
  "readOnly": true
}

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 tools and any declared MCP servers are still reachable, as intended — those are the grant.
  • openai-api and anthropic enforce readOnly by 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 from character.mcpServers alone. 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 for mcp__<name> on claude-cli.
  • codex-cli and claude-tmux are not covered. A readOnly character is refused on them at boot and they are stripped from a readOnly fallback 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.
  • 651 / strict MCP config — the other half of the same boundary: a character's MCP

    grant is the whole grant, always.
  • 649 noted that persona and tool grant share one file; #653 showed part of the grant

    was not in the file at all. This change makes the file able to say "and nothing else".