Skip to content
tnsaiDocs
GitHub

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.

RiskLabelAssigned to
READ_ONLYlowfile_read; shell commands such as ls, cat, grep, find, jq
BUILD_TESTlowShell commands such as mvn, gradle, npm, cargo, go, make, python
WRITEmediumfile_write; shell commands such as mkdir, cp, mv, sed, git add, git commit
NETWORKmediumShell commands such as curl, wget, ssh, rsync, ping
DESTRUCTIVEhighgit_write; shell commands such as rm, git reset, git clean, and any command containing --force or -rf
UNKNOWNhighAny other tool, including git_read and tools a client registers over MCP; any other shell command
ESCALATIONcriticalShell 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

Levellowmediumhighcritical
FULL_AUTORunRunAskAsk
SUPERVISED (default)RunRunAskAsk
CAUTIOUSRunAskAskAsk
MANUALAskAskAskAsk

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:

  1. Rejects ESCALATION. It broadcasts an error with code TOOL_REJECTED and records rejected.
  2. Runs the call when the autonomy level does not ask for this risk, and records auto_approved.
  3. Runs the call when the tool is trusted for this session, and records trusted.
  4. 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"}
DecisionResultRecorded as
approveThe call runsapproved
alwaysThe call runs, and later calls to the same tool in this session run without askingapproved
Anything else, such as rejectThe call is refusedrejected
No answer within 120 secondsThe call is refused, and an error with code APPROVAL_TIMEOUT is broadcasttimeout

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.