Skip to content
Console
Browse documentation
Guide

Contain an agent

Request, approve, verify, and lift an agent quarantine without overlooking failed enforcement.

On this page

For a single credential, start with Revoke an agent's access.

Ask your agent

"Ask for a freeze of writes on the checkout agent. The reason is that it is retrying a failing tool call in a tight loop."

"List the containments that are waiting for approval, and tell me which credential each one names."

Job MCP tool or action CLI command
Ask for a containment request_quarantine (write action) anectico quarantines request
List containments list_quarantines (read action) anectico quarantines list
Read one containment and its enforcement get_quarantine (read action) anectico quarantines get
Approve a pending containment (a different person) approve_quarantine (write action) anectico quarantines approve
Lift an active containment lift_quarantine (write action) anectico quarantines lift
Re-apply enforcement that failed reenforce_quarantine (write action) anectico quarantines reenforce
Withdraw a request nobody approved withdraw_quarantine (write action) anectico quarantines withdraw

Your agent can raise the alarm and read the result. Reading and requesting need quarantine:request plus the MCP umbrella scopes. Approving, lifting and re-enforcing need quarantine:approve. Stopping an agent is a decision for people, so the approval is a separate step by a different person. That person approves in the Console, as described next, or runs the same approval under their own credential.

Contain an agent

Use quarantine to freeze writes reversibly, act on an agent asset or observation key, or require two-principal approval before disabling credentials. For an emergency affecting a known credential, direct revocation does not require the quarantine approval flow.

In the Console, open Agent safety (/agent-safety). It is open to anyone who holds quarantine:request or quarantine:approve. It lists the containments that wait for a second person, and the ones that are active or closed. From there you request a containment, approve one, lift one, or withdraw a request. Requesting and approving are two separate controls, and the page refuses to offer you the approval of a request you raised yourself. See The Console.

Approval requires two different principals

A containment requires two different principals. A request changes no credential; approval applies the containment. The requester cannot approve their own request, including through a script using the same credential.

quarantine:request is granted to every role except viewer, deliberately — sounding the alarm should not require finding an admin first. quarantine:approve is owner or admin only, because approving is what disables a production credential; the same scope is required to lift a containment afterward. Withdrawing a request nobody approved is the one act that sits with the requester: see Withdraw a request nobody approved. See Permission scopes for both.

Three actions, and what each one actually does

Action What it does Reversible?
freeze_writes (default) Strips every write-shaped scope from each credential involved, keeping only the scopes that end :read. The original scope set is stashed so a lift restores it exactly. Yes — the only reversible action
revoke_credentials Revokes each credential outright — the same durable revocation described here. No. There is no un-revoke
kill Stops write access and permanently revokes the approved credentials and their descendant keys. When the target resolves to an agent asset, it also installs a standing ban on minting a credential for that agent; a direct API-key target has no agent on which to install that ban. Only the freeze half; the ban lifts with the containment

Read freeze_writes literally: it removes every scope that is not read-shaped. A typical SDK ingest key holds only ingest:write and nothing else, so freezing it reduces that key to zero scopes. A frozen ingest key's telemetry stops arriving. That is the point of freezing an agent's telemetry credential, not a side effect to route around — if you need the agent's data to keep flowing while you investigate, freezing that key is the wrong action.

The same reading has a second edge. A key that only reads, such as the one an app you host uses to show service health, holds nothing a freeze removes, so a frozen read-only key keeps reading. To stop it, revoke it: see Stop an app that reads.

revoke_credentials calls the exact same durable revoke as the CLI revoke command — same 401 on the very next request, same permanence. Everything in What stops immediately, What finishes rather than stopping, and What revocation cannot take back is equally true of a quarantine that revokes.

What a containment names

A target is one of three things, not always a key you already hold:

  • an asset id, if the agent is in your inventory — resolves to every credential that has produced that asset's telemetry
  • an observation key, for an agent nobody declared — the attribution lookup returns this
  • an API key id directly, when you already know exactly which credential to stop

The request body must supply exactly one of asset_id, observation_key, or api_key_id. Supplying none or supplying several is a 400; Anectico never chooses one by priority. action may be omitted, in which case it defaults to freeze_writes. If it is present, it must name freeze_writes, revoke_credentials, or kill; a blank or unknown spelling is a 400 and creates no containment request.

An API key id is checked against your organization's keys when the containment is approved, and against the project the containment is scoped to. A well-formed id that names no key of yours — one wrong character, a key belonging to somebody else, or one belonging to a different project of your own — is refused at that point with a 404, rather than producing a containment that reports success over a credential it should not reach. Revoking is permanent, so this check runs before the approval is signed. A key created with org_wide belongs to no project and stays reachable from any of them. The same rules apply to an asset id outside the scope you named.

Request, approve, verify, lift

# 1. Request — any member or developer can raise this
curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/quarantines" \
  --data '{
    "asset_id": "asset_9f2c...",
    "action": "freeze_writes",
    "reason": "Agent is retrying a failing tool call in a tight loop"
  }'

A successful request answers 201. Its quarantine.quarantine_id is what the next three calls act on, and quarantine.status reads "QUARANTINE_STATUS_PENDING" — nothing has been enforced yet.

request_id is yours to choose, and re-sending the same one returns your existing request instead of opening a second containment. It is scoped to the person who used it first: if somebody else in your organization already used that id, you get a 409 telling you the id is taken — and nothing about their request. Two people running the same runbook need two ids, or one of them will be told to pick another.

# 2. Approve — a DIFFERENT principal, holding quarantine:approve. Reusing the
# requester's own key here is refused: the platform can tell from the credential
# that made the request, and rejects an approval that would make the two-person
# rule a formality.
curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_APPROVER_API_KEY" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID/approve"
# 3. Verify — check what actually happened
curl --fail-with-body \
  -H "X-Anectico-API-Key: $ANECTICO_APPROVER_API_KEY" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID"
# 4. Lift — when the incident is over
curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_APPROVER_API_KEY" \
  -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID/lift" \
  --data '{"reason": "Root cause fixed, agent redeployed with a corrected retry budget"}'

The same four steps from the CLI:

anectico quarantines request --asset asset_9f2c... --action freeze_writes \
  --reason "Agent is retrying a failing tool call in a tight loop" --request-id loop-2026-09-28
anectico quarantines approve "$QUARANTINE_ID" --confirm-token <token>          # run by a different person
anectico quarantines get "$QUARANTINE_ID"
anectico quarantines lift "$QUARANTINE_ID" --reason "Root cause fixed" --confirm-token <token>

anectico quarantines reenforce re-applies an active containment's approved enforcement, and anectico quarantines withdraw --reason ... retracts a request that was never approved.

An MCP agent reaches the same lifecycle through the gateways: list_quarantines and get_quarantine from list_read_actions, and request_quarantine, approve_quarantine, lift_quarantine, reenforce_quarantine and withdraw_quarantine from list_write_actions (see Ask your agent). request_quarantine requires a request_id. Every write, including a new request, previews first and applies with the returned confirm_token. A confirmation token or a second API key of the same person is never a second approver: the approval must come from a different person, and is refused otherwise.

Lifting also takes quarantine:approve — restoring capability to an agent someone decided to stop is treated as the same grade of decision as stopping it in the first place. A lift restores whatever a freeze reduced. It does not undo a revoke: see Three actions, and mint a replacement credential the normal way once you've lifted.

A lift frees only what no other containment is still holding. One key often serves several agents, so two containments raised against two different agents can land on the same credential. Lifting one of them leaves that shared credential frozen while the other is still active, and says so: the credential appears under quarantine.credentials with detail_code held_by_another_quarantine. Lift the other containment and the credential comes back — whichever lift is the last to let go is the one that restores it. Without that rule, lifting the first containment would hand the agent its capability back while the second one still reported it contained.

Two people lifting two overlapping containments at the same moment is safe. Whichever of the two ends up last still releases the shared credential, so simultaneous lifts cannot leave an agent frozen by a containment that no longer exists.

To see everything currently open or historical, GET /api/v1/quarantines?status=pending (repeat status for more than one value) lists across your organization, newest first. The status filter takes the lowercase short form (pending, active, lifted, withdrawn); the quarantine.status field in the response is the full enum name (QUARANTINE_STATUS_PENDING, and so on).

Withdraw a request nobody approved

Not every request should end in a containment. An alarm raised on a hunch that turns out to be a deploy, a runbook fired twice, a request overtaken by the agent being taken offline anyway — all leave a pending row, and a pending row is not inert: while a containment is pending, no second one can be raised against the same agent. Leaving it there means the next person who genuinely needs to contain that agent is refused.

curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID/withdraw" \
  --data '{"reason": "The retry storm was a bad deploy, not the agent"}'

Withdrawing your own request takes only quarantine:request — the scope you used to raise it. That is deliberate and it is the one place the two containment scopes are not symmetric: a pending request has enforced nothing, so retracting it hands capability back to nobody, which is the thing quarantine:approve is owner or admin only to protect. Requiring an admin to clear a row that never had an effect is how such rows come to be left alone.

Withdrawing somebody else's takes quarantine:approve. Retracting another person's request suppresses a containment before a second principal has had the chance to consider it, which is the same decision as refusing one.

Three things to expect:

  • A withdrawal reaches no credential, because a pending request never touched one. Nothing is frozen, released, revoked or restored by it.
  • It only works on a pending request. An approved containment is ended with a lift, which restores credentials; a withdrawal on anything else is refused with 400.
  • The withdrawn request keeps its request_id, and re-sending that id returns the withdrawn record rather than opening a new containment. Raising the request again means choosing a new request_id.

Like a lift, a withdrawal requires a reason, and the record stays: status reads "QUARANTINE_STATUS_WITHDRAWN", with withdrawn_by, withdrawn_at and withdraw_reason. "Somebody asked to stop this agent and then thought better of it" is a fact an incident review wants, and deleting the row would lose it.

Reading the status

quarantine.status is "QUARANTINE_STATUS_PENDING" (requested, nothing enforced), "QUARANTINE_STATUS_ACTIVE" (approved and enforced), "QUARANTINE_STATUS_LIFTED" (released after having been enforced — the record stays, because "this agent was stopped for six hours last Tuesday" is exactly the fact an incident review needs), or "QUARANTINE_STATUS_WITHDRAWN" (retracted before anyone approved it, so it was never enforced at all).

"QUARANTINE_STATUS_LIFTED" and "QUARANTINE_STATUS_WITHDRAWN" are separate on purpose. Both are endings, and they say opposite things: one agent was stopped and let go, the other was never stopped. A count of your containments should not include the ones that never happened.

A quarantine that has ended also carries why: lift_reason on a lifted one, withdraw_reason on a withdrawn one, beside the lifted_by/lifted_at and withdrawn_by/withdrawn_at pair.

Each credential a quarantine resolved to is reported individually, under quarantine.credentials, with its own state — one containment can legitimately freeze one key and find a second already revoked, and a single rolled-up answer would misreport the pair. "ENFORCEMENT_STATE_VERIFIED" means the platform's own credential store now denies the credential on its very next request — that is a fact about the credential, not a promise that no more telemetry has arrived, because confirming an absence would mean waiting and guessing how long is long enough.

"ENFORCEMENT_STATE_UNKNOWN" is not a failure — it is an honest answer to a question containment cannot always fully close. You will see it when:

  • some of the target's recorded telemetry carried no credential the platform can revoke at all — a signed-in person's session, or a sighting recorded before this feature existed to track which credential produced it
  • the target resolved to no credentials at all, in which case reporting "ENFORCEMENT_STATE_VERIFIED" would claim to have revoked nothing while sounding like containment succeeded
  • a credential this containment named was already frozen by an earlier one, so this containment changed nothing about it (detail_code already_contained). The credential is contained — and it stays contained until every containment naming it has been lifted — but the effect belongs to the earlier one, and claiming it here would be this containment taking credit for another's work
  • the credential no longer exists in your organization (detail_code no_such_credential), which is a different fact from having contained it

quarantine.unenforceable_sightings tells you how many sightings fall into the first case. A non-zero count is not a bug report: it means some of what this agent did did not arrive on anything the platform can take away, and the honest status for that is "ENFORCEMENT_STATE_UNKNOWN", never a false "ENFORCEMENT_STATE_VERIFIED".

kill and its third clause — read this before you rely on it

kill also stops a new key being minted for the agent, but only for keys that say which agent they are for.

A key can be minted with a declaration: the id of the agent it is being created for. Pass --agent to anectico agent bootstrap or anectico apikey create, or send "agent_asset_id" in the body of POST /api/v1/account/api-keys. A declared key gets two things an undeclared one does not:

  • while a kill containment is live on that agent, minting another key for it is refused — a 400 whose details.reason is AGENT_MINT_BANNED and whose details.asset_id names the agent, so you can find the containment that refused you;
  • a containment can reach it before it has ever been used. Anectico normally learns which key belongs to which agent by watching telemetry, so a key you minted and have not deployed yet is invisible to that. A declared key is not.

The declaration has to name a real agent in the same project as the key. An id that matches nothing, one belonging to another project, or one naming something that is not an agent is refused with a 400 before any key is created — so a declaration is a fact about your inventory rather than a string you typed. Two consequences worth planning around: declare an agent only once it appears in anectico fleet list (a brand-new agent has no id until its first telemetry arrives, which is why anectico agent bootstrap warns rather than refusing when --agent is omitted), and an org-wide key cannot carry a declaration at all, because an agent belongs to exactly one project.

A key minted for that agent while the kill is being applied does not slip through. The ban and the list of keys it acts on are taken as one act, so a mint either lands before the ban — in which case the same kill revokes it, and its row in the containment says credential_minted_during_kill — or it arrives after and is refused.

An API key cannot mint its way out of containment. Child keys inherit the minting key's stored agent and project, even when agent_asset_id is omitted. Choosing another agent is refused. An unbound key cannot mint children: use your own personal session to enroll an agent-bound root key first. An approved OAuth connection cannot mint keys because it has no API-key parent to bind them to. Child keys also cannot outlive their parent.

KILL discovers recorded descendants, including older keys minted without an agent or with a different attribution. Revoking a parent invalidates all its descendants on their next request, through both management and intake. An expired parent has the same effect. A frozen parent blocks its descendants until it is released; the frozen key itself keeps its read scopes.

An unrelated root credential remains outside the asset's ban. A person using their own session can create an unbound root key and hand it to an agent. Anectico cannot infer that handoff. The first telemetry from that key supplies the missing link: Anectico then raises a critical review signal naming the non-secret key id, the killed agent, the active containment, and the first and latest sighting times. It raises one row per key and containment, not one per span.

That signal appears in the review queue, sends opening and clearing notifications, and can be targeted by an alert rule using the review_signals source. It contains nothing and revokes nothing; a person still decides whether to revoke the key. Repeated sightings do not send another opening. Revoking it or lifting the named containment closes the row. The window before first telemetry remains real, so if your recovery plan depends on "nobody can re-provision this agent", the process control is still yours: mint agent root keys with --agent so the ban can bind, and control who can provision replacement roots.

Every kill tells you which case you are in. Its enforcement points include one for the mint path:

What you see What it means
mint_ban_declared_only The ban covers enrolled roots and inherited child bindings. Recorded descendants are included in KILL. Unrelated roots remain outside the ban.
mint_ban_no_asset No ban was installed at all — the containment named an API key directly, or an agent Anectico has not yet built an inventory entry for.
enforcement_failed The ban could not be installed. Re-enforce the containment — do not lift it.

Lifting the containment restores minting. If two containments name the same agent, both have to be lifted — and the lift tells you when the ban survives because another one still holds it. A lift of a containment whose ban was never installed says nothing_to_restore rather than quarantine_lifted, so the record keeps saying that the third clause was never in force.

The inventory lifecycle follows the same cover-set rule. Two different targets can resolve to the same agent; lifting either one leaves that agent quarantined while the other containment is active, and the last lift restores the lifecycle state the agent had before the first containment began.

If a lift fails partway, run it again. Lifting is idempotent: a repeated lift on a containment that has already been released finishes whatever the first attempt did not, rather than refusing you. Retrying is always the right first move.

If part of a containment failed, re-enforce it

A containment is recorded before anything is applied, so an approval can succeed while one of its effects does not — a credential that could not be reached, or a mint ban that could not be installed. The containment then reads active with a failed row inside it, which is the truth and not a bug.

Do not lift it to fix it. Lifting restores the credentials and re-opens the mint path, which for a kill re-opens the agent in order to repair the thing that was meant to keep it shut. Re-enforce instead:

curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID/reenforce"

It re-runs only what is recorded as failed, under the approval the two principals already made — it approves nothing new and needs no second person. retried in the response says how many things were re-attempted; 0 means there was nothing left to repair, which is a success rather than an error. It takes the same permission as approving and lifting.

A contained key cannot be edited around

While a key is frozen, its scopes cannot be changed by the ordinary key-editing route: a PATCH /api/v1/account/api-keys/{id} that carries scopes is refused with a 400 whose message names the containment. Renaming still works.

This is deliberate, and it matters because the two authorities differ. Editing a key's scopes is something every member and developer can do; approving and lifting a containment is owner or admin only. Without the refusal, anyone who could edit a key could hand a stopped agent its write scope back on the next request — with nothing linking that act to the containment somebody else approved. It also keeps the lift honest: the original scope set is stashed when the freeze happens, so a change made while frozen would either be overwritten by the lift or, worse, silently re-granted by it. Lift the containment first, then edit.

A frozen key is visible as frozen. GET /api/v1/account/api-keys reports frozen: true with a frozen_at timestamp for every contained key, so a key whose scope list has shrunk is explained rather than mysterious. It is not the same as revoked: a frozen key still authenticates, and a revoked one is gone for good.

Review the complete containment preview

A request previews a pending record and enforces nothing. Its preview identifies the exact target, resolved credential ids, proposed access changes, other agents sharing those credentials, and any sightings that cannot be enforced. Approval by a different person is a separate confirmed write. Its preview names exactly what stops and for whom. freeze_writes retains only scopes ending :read; telemetry from an ingest-only key stops; revocation is permanent. kill also bans new credentials declaring the resolved agent. These controls do not terminate an arbitrary remote operating-system process.

Approval, lift and re-enforcement check the previewed containment state and credential resolution. A lift also reports credentials held by another containment and the saved asset lifecycle it will restore. Review every enforcement outcome afterwards; unknown or failed means unproved or failed. The list is a page: use cursor and limit and follow pagination.next_cursor with the same filters.

The Console uses the same server preview as REST, MCP and the CLI for requests, approvals, lifting and withdrawal. Review the complete current and proposed state, affected credentials and required permissions before confirming. A request retains its stable request id; an uncertain retry uses the same reviewed arguments and confirmation token. Closing a review after an uncertain confirmation retains the pending request for review and retry. Approval records the decision; inspect the refreshed per-credential enforcement results before concluding that every effect succeeded.