aegis · Guides
Hold, approve and stop tool calls
Require operator approval for high-risk MCP tools, stop traffic instantly with the kill-switch, and release quarantined tools in aegis.
On this page
aegis gives operators four runtime controls over tool calls. You can hold a risky tool for approval, stop traffic with a kill-switch, release a quarantined tool and triage shadow findings. Each section below is a procedure, and the examples run against the Compose stack from Quick start.
The three refusals come back as HTTP 200 with a JSON-RPC error body, so check error.code.
Before you start#
DATABASE_URLmust be set. Without it, every endpoint on this page returns HTTP 503. The Compose stack sets it.$ADMINholds a token for an operator: adefault-tenant subject withcatalog:readandcatalog:writeonaegis-admin. On the Compose stack, use the token you minted fore2e-testerin Quick start, step 2.$CALLERholds a token for a different subject, because nobody can approve their own request. On the Compose stack, rerun the Quick start, step 2 recipe withk6-loadin place ofe2e-tester.k6-loadis the other seeded subject with theadminrole.- On the Compose stack, lift the test quota first (Quick start, step 3).
Require approval for a risky tool#
An approve policy holds each matching call until an operator decides.
- Create the policy. This one holds calls that the
adminrole makes toaddonmock-primary:
curl -s -X POST http://localhost:8080/admin/v1/policies -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"role":"admin","server":"mock-primary","tool_pattern":"add","effect":"approve"}'
The response is HTTP 201 with the policy id. The change takes effect within POLICY_REFRESH_SECS (default 30; the Compose stack sets 2).
- As the caller, send the call. Keep the body in a variable so you can re-send it unchanged:
CALL='{"jsonrpc":"2.0","method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}},"id":1}'
curl -s -X POST http://localhost:8080/v1/invoke -H "Authorization: Bearer $CALLER" -H 'Content-Type: application/json' -d "$CALL"
{"jsonrpc":"2.0","error":{"code":-32061,"message":"Approval required for add on mock-primary","data":{"approval_id":"<approval-id>","status":"pending"}},"id":1}
The call did not reach mock-primary. Re-sending it while it is pending returns the same approval_id.
- As the operator, list pending requests:
curl -s http://localhost:8080/admin/v1/approvals -H "Authorization: Bearer $ADMIN"
Each entry under pending has an id, subject, server, tool, requested_at and args_summary. The list returns up to 200 requests from your tenant, newest first. The summary gives argument names and payload size, never values: add(args: a, b) [40 B].
- Decide. This needs
catalog:write. Send{"decision":"deny"}to refuse:
ID='<approval-id>'
curl -s -X POST "http://localhost:8080/admin/v1/approvals/$ID" -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"decision":"approve"}'
- As the caller, re-send the identical call. It runs once. A third send is held again under a new
approval_id.
curl -s -X POST http://localhost:8080/v1/invoke -H "Authorization: Bearer $CALLER" -H 'Content-Type: application/json' -d "$CALL"
How approvals behave:
- Precedence is deny, then approve, then allow. The
adminrole allows*everywhere, and theapproverule still holdsadd. A matchingdenyrefuses outright with-32003and HTTP 403. A held tool stays in the caller'stools/list. - Single-use and exact. An approval is keyed by tenant, caller, server, tool and a SHA-256 of the whole
paramsobject. New argument values need a new approval, and so does any per-request field insideparams, such as a_metaprogress token. Key order does not matter. The approval is used up when the call passes the approval check, even if input validation, a threat check or the upstream refuses it afterwards. - Separation of duties. Deciding your own request returns HTTP 404, as an unknown or already-decided id does. Operators decide requests from their own tenant only.
- Time-bound. A decision counts for
APPROVAL_TTL_SECS(default 3600), measured from when the call was first held. An approval given after that window does not release the call: the next send is held under a newapproval_id. A denial refuses the same call for the same window, with-32061and"status":"denied". - Fail closed. If the Postgres approvals store is unreachable, the call gets
-32061with"status":"unavailable". - Recorded.
aegis_approvals_total{state}countsrequired,grantedanddenied. Held calls and decisions are both audited.
The /dashboard approvals pane stays empty in live mode, so decide through the API. In production, give approvers their own role, and keep catalog:write away from agent identities, because it also lets a subject edit RBAC. When you finish, remove the policy with DELETE /admin/v1/policies/<id>.
Stop traffic with the kill-switch#
The kill-switch stops tools/call traffic for a tenant, a server or a tool, without waiting for a policy refresh.
curl -s -X POST http://localhost:8080/admin/v1/killswitch -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"action":"activate","scope":"tool","tenant":"default","server":"mock-primary","tool":"echo"}'
{"action":"activate","key":"x:default/mock-primary/echo"}
scopeistenant,serverortool.tenantis always required,serverfor theserverandtoolscopes, andtoolfor thetoolscope. Values must be non-empty and contain no/or:.- Matching calls get
-32062as soon as aegis resolves the owning server, before RBAC, approvals and threat checks. No upstream request is made. The tool stays intools/list, and the admin API keeps working, so you can lift a tenant-wide switch. - Lift a switch with the same body and
"action":"deactivate". Switches do not expire. GET /admin/v1/killswitchlists active keys underkillswitches:t:for a tenant,s:for a server,x:for a tool.- Writes are operator-only: a
default-tenant token withcatalog:write. Other tenants get HTTP 403, so a contained tenant cannot lift its own switch. Tenant admins can list their own tenant's switches. - Switches live in the Redis set
aegis:killswitch. The replica that sets one enforces it at once, and the others withinKILLSWITCH_REFRESH_SECS(default 2). - If Redis is unreachable, switches already set stay in effect, and a new change returns HTTP 503 and changes nothing.
- Changes are audited, and
aegis_killswitch_blocked_total{scope}counts refused calls.
Release a quarantined tool#
aegis pins each tool's name, description and inputSchema by SHA-256 on first sight and re-checks every INTEGRITY_RESWEEP_SECS (default 15). In the default enforcing mode, any change quarantines the tool, including an ordinary upstream upgrade. A quarantined tool leaves tools/list, and calls get -32050. Quarantine is sticky: reverting the upstream does not clear it.
- List what is held in your tenant, as
serverandtoolpairs underquarantined:
curl -s http://localhost:8080/admin/v1/quarantine -H "Authorization: Bearer $ADMIN"
-
Review the new definition on the upstream itself, since aegis does not serve it. The gateway log records a
TOOL INTEGRITYerror with the old and new hashes in itspinnedandseenfields. -
If you trust the change, release the tool. This needs
catalog:write:
curl -s -X POST http://localhost:8080/admin/v1/quarantine -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"server":"mock-primary","tool":"echo"}'
aegis drops the old pin and re-sweeps at once, so the current definition becomes the new baseline. The release is audited. A pair that is not quarantined returns HTTP 404. If you do not trust the change, leave it held and disable the server (next section).
Triage shadow findings#
curl -s http://localhost:8080/admin/v1/shadows -H "Authorization: Bearer $ADMIN"
Findings compare tool names across the servers in your tenant. Each finding under shadows has a kind:
exactwhen two names match after lowercasing, removing_and-, dropping zero-width characters and folding fullwidth letters and common Cyrillic and Greek look-alikes to ASCII.typosquatwhen two folded names of at least five characters are within two edits of each other. Pairs that differ only by trailing digits or a trailings, such asquery_v1andquery_v2, are not reported.
server and tool name one side of the collision, and shadows_server and shadows_tool name the other. distance is the edit distance, 0 for exact. The catalog is ordered by server name, so server is the side whose name sorts later, not necessarily the newer or less trusted one. When two servers expose exactly the same name, calls to it route to the server whose name sorts first. Findings refresh at startup, every SHADOW_SWEEP_SECS (default 60) and after each catalog change.
The report never blocks, because unrelated servers often share tool names. Check both servers, then for each finding choose one:
- Leave it, if the collision is expected.
- Disable the server you do not trust. This takes it out of routing and keeps its catalog entry:
SERVER='name-from-the-finding'
curl -s -X PATCH "http://localhost:8080/admin/v1/servers/$SERVER" -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"enabled":false}'
- Remove it with
DELETE /admin/v1/servers/<name>. This also deletes the policies scoped to that server and reports the count inpolicies_deleted.
Both need catalog:write and re-run the sweep, so a resolved finding clears at once. To stop a server's traffic while you investigate, set a server-scope kill-switch.