Read-Only Enforcement Posture
How-to page. This explains how to make an AxonFlow deployment refuse write-path tool calls at the MCP boundary. For per-category detection actions (PII / SQLi), see Detection Posture; for how each decision is recorded, see Audit Logging.
Available on the platform agent in all editions.
When you give an AI agent access to MCP tools, the highest-leverage safety control is often the simplest one: let it read anything it needs, but never let it write. A read-only posture is the right default for evaluations, demos, dry-runs, and any agent fronting systems where an accidental mutation is worse than a missed action.
Before this feature you could approximate read-only behavior with hand-authored policy patterns plus human-in-the-loop gates, but that meant naming every write tool by hand and keeping the list current. The read-only posture makes it a single toggle: turn it on, and every write-path MCP tool call is blocked at the gate before it reaches an internal system, while read-path calls flow through to normal governance. No per-MCP-server code, no per-tool policy authoring.
Enabling it
Set one environment variable on the agent:
MCP_READ_ONLY=true
Accepted truthy values are true, 1, and yes (case-insensitive); false, 0, and no (the default) leave the posture off. With it off, behavior is byte-for-byte identical to a deployment that never heard of the feature.
That is the entire configuration. There is no policy to author and no migration to run.
Where it is enforced
The toggle is honored on every MCP request-phase plane the agent exposes, so a write cannot slip through whichever entry point a client happens to use:
| Plane | Endpoint | What it gates |
|---|---|---|
| MCP server tool | POST /api/v1/mcp-server (check_policy) | The advisory pre-tool check a plugin hook calls before running a tool. |
| PEP request gate | POST /api/v1/mcp/check-input | The SDK / Decision-Mode gate; a PEP forwards the call only if this returns allowed, so a write-path call returns allowed:false here. |
| Connector execute | POST /mcp/tools/execute | The real side-effect path; a write-path call is refused before connector.Execute, so no command reaches the connector. |
| Connector query | POST /mcp/resources/query | Executes a raw statement on the connector. The statement verb is classified (SELECT/SHOW/... read; DELETE/UPDATE/DROP/... and data-modifying CTEs write; stacked statements rejected), fail-closed on an unparseable statement, so a write DML is refused before connector.Query. |
| Gateway pre-check | POST /api/policy/pre-check (data fetch) | The pre-check's optional MCP data fetch runs the caller's query through connector.Query. The same statement-verb classifier refuses a write-path or stacked query before the fetch. |
On each plane the posture runs before policy evaluation and the override flow, and a block is recorded the same way (see below).
How a call is classified
The posture has to decide, for each tool call, whether it is a read or a write. The rule is deterministic and applies to the tool identifier (connector_type, e.g. claude_code.Write, files.create_note, db.Query) together with the call's operation field:
- Take the method name. The segment after the last
.,/, or:separator is the method (claude_code.Write->Write,tools/execute->execute). The connector prefix is ignored on purpose, so a connector named with a read verb (saysearch.index_record) can never mask a write method. - Tokenise it. The method is split into lowercase words on separators and at camelCase boundaries (
getUserList->get,user,list). - Write wins. If any token is a write verb, the call is a write. A name that carries both a read and a write verb (
read_or_write) is treated as a write. This is the fail-safe choice for a read-only boundary. - Then read. Otherwise, if any token is a read verb, the call is a read.
- Fall back to
operation. If the name is inconclusive, the explicitoperationfield decides:queryis a read;execute, empty, or anything unrecognised is a write. Unknown calls default-deny, so a write can never slip through an unclassified name.
This mirrors the connector contract AxonFlow already uses: an MCP Resource (Query) is read-only, while an MCP Tool (Execute) is a write.
The connector query plane (/mcp/resources/query) and the gateway pre-check data fetch run a raw statement verbatim, so the posture classifies the statement verb instead of the tool name. A statement that begins with SELECT, SHOW, EXPLAIN, DESCRIBE, or a read-verb operation name is read; INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/MERGE/... and data-modifying WITH CTEs are write; an empty or unparseable statement fails closed to write.
The statement classifier fails closed on ambiguity rather than best-guess (it is the early-reject layer; a read-only database transaction is the durable backstop). String-literal and comment content is masked before classification, so a ; or keyword hidden in a string or comment can neither hide a separator nor fake one, and ''/"" escaping is handled. It additionally rejects:
- Stacked / multi-statement batches (e.g.
SELECT 1; DELETE FROM t, which a SQL connector runs as a whole), since a read-led batch can still carry a trailing write. SELECT ... INTO <table>, which creates a table.EXPLAIN ANALYZE/EXPLAIN ANALYSE, which actually executes the embedded statement (soEXPLAIN ANALYZE DELETE FROM tmutates). A plainEXPLAINonly plans and stays read.- Dollar-quoted statements (
$$...$$/$tag$...$tag$) outright. A dollar-quoted body can hold arbitrary'/;/--that would desync a naive splitter (e.g.SELECT $$ ' $$ ; DELETE ...), so any dollar-quote is blocked rather than parsed; dollar-quoting in a read query is rare. Postgres parameter placeholders ($1,$2) are not dollar-quotes and are unaffected. - An unterminated string or comment (a classic splitter-evasion shape).
Verb vocabulary
| Class | Method-name tokens (sample) |
|---|---|
| Read | read, get, list, search, query, fetch, describe, find, grep, glob, view, show, cat, select, count, lookup, inspect, scan, download, status, watch |
| Write | write, edit, create, update, delete, insert, drop, put, post, patch, remove, exec, execute, run, bash, shell, move, copy, rename, set, push, commit, send, truncate, alter, deploy, apply, upload, add, merge, transfer, grant, revoke, register, reset, mkdir, enqueue |
Shell-style tools (bash, shell, run, exec) are classified as write because an arbitrary command can mutate state; a read-only posture blocks them rather than guessing.
Classification at a glance
| Tool call | operation | Classified | Result with posture on |
|---|---|---|---|
claude_code.Read | (any) | read | allowed |
files.search_notes | (any) | read | allowed |
db.Query | query | read | allowed |
claude_code.Write | (any) | write | blocked |
files.create_note | execute | write | blocked |
claude_code.Bash | (any) | write | blocked |
custom.frobnicate (unknown) | query | read | allowed |
custom.frobnicate (unknown) | execute / empty | write | blocked |
What a block looks like
When the posture blocks a call, the check_policy response is a normal AxonFlow deny, marked so a plugin can render it clearly:
{
"allowed": false,
"decision_id": "ad21077e-cc1b-4740-8334-367481e02461",
"block_reason": "read-only posture active: write-path tool call \"files.create_note\" is blocked; only read-path operations are permitted",
"blocked_by": "sys_mcp_read_only_posture",
"read_only_posture": true
}
The check-input and tools/execute planes return the standard block for their endpoint (HTTP 403, allowed:false) carrying the same decision_id. On every plane the block is recorded as a canonical audit row (plane=mcp, policy_decision=blocked, request_type of the plane: mcp_check_policy, mcp_check_input, or mcp_tools_execute) attributed to the synthetic policy id sys_mcp_read_only_posture, so the deny is visible in the portal decisions feed and the audit log like any other decision.
How it relates to your other policies
The read-only posture is an independent, deployment-wide boundary that runs before the rest of policy evaluation:
- It is evaluated first. A write-path call is blocked by the posture even if no content policy would have matched it, and the block reason names the posture rather than a content rule.
- It is not overridable. Unlike a content-policy deny, a read-only block cannot be lifted with a session override (ADR-044). The posture is a safety boundary, not a per-policy decision, so it holds for every caller until an operator turns it off.
- Read-path calls still pass through normal governance. A read that the posture allows is still evaluated by your static and dynamic policies and the detection posture (PII, SQLi, dangerous-operation actions). Read-only does not weaken content governance; it adds a write boundary on top of it.
Limits and what it trusts
The gate classifies the tool call from the caller-declared identity (connector_type) and operation field, the same inputs every check_policy decision is based on. Two properties keep that safe:
- A recognised write verb always blocks, regardless of the
operationfield. The write vocabulary is deliberately broad (create, update, delete, insert, upload, add, merge, transfer, grant, revoke, register, reset, mkdir, deploy, run, exec, and many more), so the common mutating verbs are caught even if a caller mislabelsoperationasquery. - Unrecognised names fail safe. A tool whose name matches no read verb and no write verb is treated as a write (blocked) unless the caller explicitly declares
operation=query.
The residual surface is narrow: a write tool whose method name contains no recognised write verb and whose caller declares operation=query. In the normal plugin integration the operation field reflects the real call, and write tools are named with a mutating verb, so this does not arise.
Database-enforced backstop
For SQL connectors the posture does not rely on the parser alone. Under MCP_READ_ONLY, a connector query runs inside a read-only database transaction (the PostgreSQL connector issues BEGIN READ ONLY), so the database itself rejects any INSERT/UPDATE/DELETE/DDL with SQLSTATE 25006 even if a write is smuggled past the statement-verb parser, for example a data-modifying function invoked as SELECT write_func(), or a future form the parser never anticipated. The parser is the fast early-reject and the non-SQL control; the read-only transaction is the durable, database-enforced guarantee. (Connectors without a read-only-transaction primitive, e.g. Cassandra, rely on the parser path only.)
A worked example
A team wants to run a Claude Code agent against a production database connector for an audit, with a strong guarantee that nothing is mutated:
- Set
MCP_READ_ONLY=trueon the agent and restart it. The startup log confirms the posture is active. - The agent issues
db.Query/SELECT ...calls. Each classifies as a read and is allowed (still subject to PII / SQLi detection on the statement). - The agent attempts
db.Execute/UPDATE accounts .... It classifies as a write and is blocked at the gate withread_only_posture: true; no statement reaches the connector. - An operator opens the portal decisions feed and sees the blocked write as a
Blockedrow attributed tosys_mcp_read_only_posture, alongside the allowed reads.
No per-tool policy was written, and no connector code changed. Turning the posture off (remove the env var, or set it to false) restores full read/write governance.
Community vs Enterprise
| Capability | Community | Enterprise |
|---|---|---|
MCP_READ_ONLY write-path enforcement at the MCP gate | ✅ | ✅ |
| Canonical audit row for each read-only block | ✅ | ✅ |
| Portal decisions feed + evidence export for the blocks | ✅ |
The enforcement lives in the platform agent binary, so the posture is honored in both editions. The authenticated portal surfaces that view and export the resulting decisions are part of the Enterprise customer portal.
See also
- Detection Posture: per-org PII / SQLi / dangerous-operation actions, the content-governance companion to this write boundary
- Managing Policies: the static and dynamic policies that still apply to allowed read-path calls
- Human-in-the-Loop: turn a hard block into a human approval when a write should sometimes proceed
- Audit Logging: how each enforcement action is recorded
- Decisions: the portal feed where read-only blocks appear
