Skip to content

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.

ControlAdmin endpointsThe caller getsMetric
Approval holdGET /admin/v1/approvals, POST /admin/v1/approvals/:id-32061aegis_approvals_total{state}
Kill-switchGET and POST /admin/v1/killswitch-32062aegis_killswitch_blocked_total{scope}
Integrity quarantineGET and POST /admin/v1/quarantine-32050aegis_threat_blocks_total{kind="integrity"}
Shadow findingsGET /admin/v1/shadowsNothing, it only reportsNone

The three refusals come back as HTTP 200 with a JSON-RPC error body, so check error.code.

Before you start#

  • DATABASE_URL must be set. Without it, every endpoint on this page returns HTTP 503. The Compose stack sets it.
  • $ADMIN holds a token for an operator: a default-tenant subject with catalog:read and catalog:write on aegis-admin. On the Compose stack, use the token you minted for e2e-tester in Quick start, step 2.
  • $CALLER holds a token for a different subject, because nobody can approve their own request. On the Compose stack, rerun the Quick start, step 2 recipe with k6-load in place of e2e-tester. k6-load is the other seeded subject with the admin role.
  • 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.

  1. Create the policy. This one holds calls that the admin role makes to add on mock-primary:
bash
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).

  1. As the caller, send the call. Keep the body in a variable so you can re-send it unchanged:
bash
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"
json
{"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.

  1. As the operator, list pending requests:
bash
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].

  1. Decide. This needs catalog:write. Send {"decision":"deny"} to refuse:
bash
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"}'
  1. As the caller, re-send the identical call. It runs once. A third send is held again under a new approval_id.
bash
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 admin role allows * everywhere, and the approve rule still holds add. A matching deny refuses outright with -32003 and HTTP 403. A held tool stays in the caller's tools/list.
  • Single-use and exact. An approval is keyed by tenant, caller, server, tool and a SHA-256 of the whole params object. New argument values need a new approval, and so does any per-request field inside params, such as a _meta progress 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 new approval_id. A denial refuses the same call for the same window, with -32061 and "status":"denied".
  • Fail closed. If the Postgres approvals store is unreachable, the call gets -32061 with "status":"unavailable".
  • Recorded. aegis_approvals_total{state} counts required, granted and denied. 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.

bash
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"}'
json
{"action":"activate","key":"x:default/mock-primary/echo"}
  • scope is tenant, server or tool. tenant is always required, server for the server and tool scopes, and tool for the tool scope. Values must be non-empty and contain no / or :.
  • Matching calls get -32062 as soon as aegis resolves the owning server, before RBAC, approvals and threat checks. No upstream request is made. The tool stays in tools/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/killswitch lists active keys under killswitches: t: for a tenant, s: for a server, x: for a tool.
  • Writes are operator-only: a default-tenant token with catalog: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 within KILLSWITCH_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.

  1. List what is held in your tenant, as server and tool pairs under quarantined:
bash
curl -s http://localhost:8080/admin/v1/quarantine -H "Authorization: Bearer $ADMIN"
  1. Review the new definition on the upstream itself, since aegis does not serve it. The gateway log records a TOOL INTEGRITY error with the old and new hashes in its pinned and seen fields.

  2. If you trust the change, release the tool. This needs catalog:write:

bash
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#

bash
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:

  • exact when two names match after lowercasing, removing _ and -, dropping zero-width characters and folding fullwidth letters and common Cyrillic and Greek look-alikes to ASCII.
  • typosquat when two folded names of at least five characters are within two edits of each other. Pairs that differ only by trailing digits or a trailing s, such as query_v1 and query_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:
bash
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 in policies_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.