Tool approval
The server checks every tool call an agent makes before it runs. It classifies the call by risk, compares the risk with the session's autonomy level, and then runs the call, rejects it, or asks the WebSocket client. While the client decides, the agent's thread waits. This page describes the server's protocol. For approvals in your own agents, see Security and approvals.
Risk levels
ToolRisk has seven values. Each has a label, and the autonomy level reads only the label.
| Risk | Label | Assigned to |
|---|---|---|
READ_ONLY | low | file_read; shell commands such as ls, cat, grep, find, jq |
BUILD_TEST | low | Shell commands such as mvn, gradle, npm, cargo, go, make, python |
WRITE | medium | file_write; shell commands such as mkdir, cp, mv, sed, git add, git commit |
NETWORK | medium | Shell commands such as curl, wget, ssh, rsync, ping |
DESTRUCTIVE | high | git_write; shell commands such as rm, git reset, git clean, and any command containing --force or -rf |
UNKNOWN | high | Any other tool, including git_read and tools a client registers over MCP; any other shell command |
ESCALATION | critical | Shell commands sudo, su, doas, pkexec, runas |
git_read counts as UNKNOWN because the filter looks for a tool named git, so git reads need approval at every autonomy level.
Shell commands
ServerShellTools.classifyCommand reads the command argument of the shell tool and classifies it by its first word, after skipping NAME=value assignments. It checks in this order: escalation, destructive, --force or -rf anywhere, network, write, then UNKNOWN for an interpreter with an inline flag (such as python -c or bash -c) or for any argument that is an absolute path other than /dev/null, then build, then read-only.
A pipeline is split on |, and the highest risk of its parts wins. Commands joined with ; or && are not split. The first word decides their risk, and only the --force, -rf and absolute-path checks see the rest of the string, so ls && rm notes.txt counts as READ_ONLY.
Autonomy levels
| Level | low | medium | high | critical |
|---|---|---|---|---|
FULL_AUTO | Run | Run | Ask | Ask |
SUPERVISED (default) | Run | Run | Ask | Ask |
CAUTIOUS | Run | Ask | Ask | Ask |
MANUAL | Ask | Ask | Ask | Ask |
FULL_AUTO and SUPERVISED behave the same today. ESCALATION never reaches this table: the call is rejected first.
A new session starts at SUPERVISED. Change it with set_autonomy. The level is case-insensitive:
{"v": 1, "type": "set_autonomy", "sessionId": "s1", "level": "cautious"}The server updates every agent in the session and broadcasts autonomy_changed with the level in upper case. An unknown level gets an error event with code INVALID_AUTONOMY.
Decision order
For each call, the filter:
- Rejects
ESCALATION. It broadcasts anerrorwith codeTOOL_REJECTEDand recordsrejected. - Runs the call when the autonomy level does not ask for this risk, and records
auto_approved. - Runs the call when the tool is trusted for this session, and records
trusted. - Otherwise asks the client and waits up to 120 seconds.
The request names the call and shows what is being approved. summary is the shell command, or the argument map for other tools, cut to 200 characters:
{"v": 1, "type": "tool_approve_request", "sessionId": "s1", "agentId": "coder",
"toolCallId": "3f9a1c2e", "tool": "shell", "risk": "high", "summary": "rm -rf build"}The client answers with the same toolCallId:
{"v": 1, "type": "tool_approve", "sessionId": "s1", "toolCallId": "3f9a1c2e", "decision": "approve"}| Decision | Result | Recorded as |
|---|---|---|
approve | The call runs | approved |
always | The call runs, and later calls to the same tool in this session run without asking | approved |
Anything else, such as reject | The call is refused | rejected |
| No answer within 120 seconds | The call is refused, and an error with code APPROVAL_TIMEOUT is broadcast | timeout |
In a session with several agents, the server hands the answer to whichever agent is waiting on that toolCallId. An answer with an unknown id is logged and ignored.
A cancel event, a closed connection or server shutdown refuses every pending request. Each one is recorded as rejected, or as timeout with an APPROVAL_TIMEOUT error when the agent's task is interrupted before the request is cancelled. cancel interrupts the task first, so it usually produces timeout.
Audit trail
Each decision is stored in the session's AuditService and broadcast as an audit_entry event:
{"v": 1, "type": "audit_entry", "sessionId": "s1", "toolCallId": "b71e04d9", "agentId": "coder",
"tool": "shell", "risk": "high", "decision": "approved", "timestamp": 1761300000000, "summary": "rm -rf build"}The entry's toolCallId is generated for the entry. It does not match the toolCallId of the approval request, so match entries to requests by agentId, tool and summary.
The trail keeps the last 500 entries per session. Read it with GET /api/audit?sessionId=s1. A missing sessionId returns 400 and an unknown session returns 404. The request must carry the session's token in the X-Session-Capability header, or it gets 403 with code FORBIDDEN_SESSION; see Server hardening.