Contain a Compromised Agent
Withdraw a process-hosted agent's credential, capability, and admission on its original host, and keep the evidence that explains each refusal.
On this page
The fixer read PR #421's fixture and started asking the shell tool for curl to collect.example.net. Take it out of service on the host that ran it. Three commands withdraw its bearer credential, its capability, and its process admission, and each result stays with the incident.
Each control arms a different check on the worker's next call. The checks run in a fixed order, so the first refusal the caller sees says nothing about the controls behind it.
StartWith every control armed, authentication refuses first
The response is the same unauthenticated shape as a revoked credential alone. The admission and capability checks behind it would each refuse on their own; dispatch order decides which one the caller sees.
1. Stop the host and fix the incident's identities
Work on the pricing host's existing state directory and the fixer process. Stop its supervised chio process serve and wait for it to exit: the administrative commands act on a stopped host's stores.
CHIO_STATE=/var/lib/larkfield/pricing-host
CHIO_PROCESS=fixer
CHIO_SOCKET=/run/larkfield/pricing-host.sock
CHIO_INCIDENT=/var/lib/larkfield/incidents/pr-421Keep the state directory, the original socket, logs, captured requests, and the receipts already written. A replacement host or a similarly named process lets a command succeed without touching the affected work, so every later step names these values.
2. Withdraw the credential, the capability, and the process
Run the three controls against that host and process. The script keeps each command's JSON result and stderr in a new private directory outside the host.
set -eu
umask 077
: "${CHIO_STATE:?Existing stopped host directory}"
: "${CHIO_PROCESS:?Existing affected process ID}"
: "${CHIO_INCIDENT:?New private incident directory outside the host}"
test ! -e "$CHIO_INCIDENT"
mkdir -m 0700 "$CHIO_INCIDENT"
chio process revoke --state "$CHIO_STATE" --process "$CHIO_PROCESS" \
> "$CHIO_INCIDENT/bearer-revocation.json" 2> "$CHIO_INCIDENT/bearer-revocation.stderr"
chio process revoke-capability --state "$CHIO_STATE" --process "$CHIO_PROCESS" \
> "$CHIO_INCIDENT/capability-revocation.json" 2> "$CHIO_INCIDENT/capability-revocation.stderr"
chio process cancel --state "$CHIO_STATE" --process "$CHIO_PROCESS" \
> "$CHIO_INCIDENT/cancellation.json" 2> "$CHIO_INCIDENT/cancellation.stderr"
chio process status --state "$CHIO_STATE" > "$CHIO_INCIDENT/host-status.json"
chio process state --state "$CHIO_STATE" --process "$CHIO_PROCESS" \
> "$CHIO_INCIDENT/process-state.json"
chio receipt list --receipt-db "$CHIO_STATE/receipts.db" --admin-all --limit 100 \
> "$CHIO_INCIDENT/original-receipts.jsonl" 2> "$CHIO_INCIDENT/receipt-page.stderr"chio process revokeremoves the process's bearer credentials, so its next call fails authentication.chio process revoke-capabilityrecords a durable kernel revocation of the process's capability and every descendant in its delegation subtree.chio process cancelpermanently stops new admissions and checkpoints for the process and its descendants.
You should see one JSON result per control, then the host status, the process state, and the first page of receipts. A failed command stops the script and keeps the results before it; reconcile the failing owner before you run the controls after it. Committed effects and captured outcomes that are still unknown stay attached to their original operations.
The kernel-wide emergency stop is a separate control. It refuses every agent that shares the kernel, stays in force across restarts until an operator resumes it, and revokes nothing.
3. Rehearse each control on a prepared host
The security-containment project applies one control at a time to a fresh host, after a useful call proves the host works, then runs all three together. Its host declares one native mailbox tool, so it needs no provider service, tool subprocess, or signed launch policy.
Before you begin
Use the installed chio, Python 3.11 or newer, and a non-root account on a Unix host with local sockets. The project holds run.py, host-config.json, and policy.yaml.
chio new security-containment --example security-containment
cd security-containment
python3 run.pyALLOW positive control: worker call completed and the host stored its mailbox message
DENY capability revocation: valid credential, kernel refused with capability revoked
DENY bearer revocation: credential rejected before evaluation (unauthenticated)
CANCEL process: later call refused (cancelled); the earlier message and its allow receipt remain
CONTAIN combined: the agent can no longer act
Evidence: .chio/<scenario>/ holds each scenario's state and receiptsYou should see these six lines. Each scenario builds its own host under .chio/<scenario>/ and prints its line only after observing the outcome on the wire or in the stopped host's mailbox and receipt stores. Run python3 run.py --reset to repeat the exercise; without it the script refuses to reuse the previous run's state and exits 2.
4. Read which check refused the call
Each refusal has the shape of the check that produced it. The worker authenticates the credential, the process runtime admits the call, and the kernel checks the capability immediately before dispatch.
| Control applied | Worker response | Receipt |
|---|---|---|
| Capability revoked | "ok": true, verdict deny | Kernel-signed deny |
| Credential revoked | "code": "unauthenticated" | None |
| Process cancelled | "code": "cancelled" | None |
| All three | "code": "unauthenticated" | None |
A revoked capability is the one refusal the kernel decides. The call completes, and its result carries a signed deny verdict whose reason reads capability has been revoked: <id>, the text of the kernel's CapabilityRevoked error. A capability whose delegation chain contains the revoked id is refused the same way, through the revocation cascade.
The worker rejects a revoked credential before the kernel sees the call, with no reason text and no receipt. Cancellation is checked after authentication and before kernel evaluation, so it writes no receipt either. With all three applied, authentication answers first and the response matches a revoked credential; each of the other two would refuse the call on its own, so the response attributes the refusal to no single control.
5. Confirm that cancellation kept the message sent before it
Cancel stops the subtree and does not undo effects the worker already completed. In the cancellation scenario, the first call sends one mailbox message, the next is refused as cancelled, and the message and its allow receipt remain.
def call(socket_path, credential, operation_key, arguments):
"""Make one authenticated worker call and return its parsed response."""
operation = {"op": "invoke", "operation_key": operation_key, "server_id": SERVER_ID,
"tool_name": TOOL_NAME, "arguments": arguments}
frame = (json.dumps({"protocol": PROTOCOL, "credential": credential, "operation": operation},
separators=(",", ":")) + "\n").encode()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(30)
client.connect(str(socket_path))
client.sendall(frame)
buffer = b""
while b"\n" not in buffer:
chunk = client.recv(65536)
if not chunk:
raise ScenarioFailure("chio process serve closed the connection without a response")
buffer += chunk
response = json.loads(buffer.decode())
if response.get("protocol") != PROTOCOL:
raise ScenarioFailure(f"unexpected protocol in response: {response}")
return response
def scenario_cancellation(sock_root):
scenario_dir = private_dir(STATE / "cancellation")
host, connection = new_worker(scenario_dir, private_dir(sock_root / "cn"))
host_dir = scenario_dir / "host"
payload = {"note": "before-cancel"}
host.start()
try:
response = call(connection["socket_path"], connection["credential"], "cancel-effect-1",
{"message_key": "cancel-effect-1", "payload": payload})
finally:
host.stop()
result = response.get("result", {})
if response.get("ok") is not True or result.get("verdict") != "allow":
raise ScenarioFailure(f"cancellation control's setup call did not complete: {response}")
cancelled = run_chio("cancel", "--state", str(host_dir), "--process", "worker")
if cancelled.get("cancelled_processes", 0) < 1:
raise ScenarioFailure(f"cancellation did not report a cancelled process: {cancelled}")
host.start()
try:
response = call(connection["socket_path"], connection["credential"], "cancel-refused-1",
{"message_key": "cancel-refused-1", "payload": {"note": "should-be-cancelled"}})
finally:
host.stop()
if response.get("ok") is not False or response.get("error", {}).get("code") != "cancelled":
raise ScenarioFailure(f"cancellation did not refuse the later call as cancelled: {response}")
messages = mailbox_messages(host_dir)
if messages != [("cancel-effect-1", payload)]:
raise ScenarioFailure(f"the mailbox store does not hold exactly the message sent before cancel: {messages}")
decisions = [(receipt["action"]["parameters"]["message_key"], receipt["decision"]["verdict"])
for receipt in receipts(host_dir)]
if decisions != [("cancel-effect-1", "allow")]:
raise ScenarioFailure(f"the receipt store does not hold exactly the allow receipt from before cancel: {decisions}")
print("CANCEL process: later call refused (cancelled); the earlier message and its allow receipt remain")The second call uses a new operation key, so a captured effect is never resent as a probe. The assertions read the refusal on the wire and the stopped host's mailbox and receipt stores separately. The host, CLI, and store helpers live in the same downloaded file.
The example's host configuration and policy
The host declares one effects mailbox and a worker child of root that holds the whole budget share and the chio-ipc send_effects tool. The policy grants every chio-ipc tool for invoke and delegate with a 3,600-second lifetime, and sets durable_admission_mode: all.
{
"schema": "chio.process.host.v1",
"policy": "policy.yaml",
"mailboxes": [{"id": "effects"}],
"limits": {"max_processes": 2, "max_depth": 1, "max_calls": 20},
"children": [
{
"id": "worker",
"parent": "root",
"budget_share_bps": 10000,
"tools": [{"server_id": "chio-ipc", "tool_name": "send_effects"}]
}
]
}kernel:
max_capability_ttl: 3600
delegation_depth_limit: 1
durable_admission_mode: all
capabilities:
default:
tools:
- server: chio-ipc
tool: '*'
operations: [invoke, delegate]
ttl: 36006. Verify the refusal in the host's own receipts
A revoked capability is the only control that leaves a kernel receipt, and only once a call presents it. The example's capability scenario made that call, so read its stopped host's store at .chio/capability-revocation/host.
HOST=.chio/capability-revocation/host
chio receipt list --receipt-db "$HOST/receipts.db" --capability <capability-id> \
--outcome deny --admin-all
chio receipt explain --receipt-db "$HOST/receipts.db" <receipt-id> --admin-allYou should see the deny receipt for the revoked capability and its explanation. For the incident's own export, verify each page under the independently retained kernel key as Investigate a session shows: chio receipt verify checks the signature, the signer pin, and the action hash offline, and chio receipt verify-process-response also binds a captured worker response to the frozen request and context it answers.
chio process state reads a retained checkpoint or an immutable blob without starting the kernel or issuing a credential. On Linux, chio process attest-run attests a completed fan-out plan, and verify-run checks that evidence on any Unix host, as process attestation describes.
The original host returns to service with every withdrawal in force
After you review the unresolved work, restart only the authorized original host, on its original state and socket. Resetting its stores, initializing a replacement, or issuing a fresh credential would change the incident you are containing.
chio process serve --state "$CHIO_STATE" --socket "$CHIO_SOCKET"Probe it with one harmless call under a new operation key, never a replayed effect or a provider write. An unauthenticated answer confirms the credential withdrawal and leaves the two checks behind it unexercised, so the step 2 results remain the evidence for those. Trace the message sent before cancellation with the incident query, and move the stopped host with retained-state relocation.
In a host that embeds active defense, a governed response plan can fence issuance and suspend a wider capability set at once. Blast radius describes how such a plan binds its exact affected set.
Process host administration
The process host's administrative commands, and the messages the CLI prints when a command addresses the wrong store.
Search for revoke, cancel, export, authority.db, or admin-all.
Process administration commands
| Command | Effect |
|---|---|
chio process credential | Issues a worker credential file for a named process |
chio process revoke | Removes the process's bearer credentials; later calls fail authentication |
chio process revoke- | Records a durable kernel revocation for the capability and its descendants |
chio process cancel | Cancels the process subtree; completed effects remain |
chio process export | Exports the host for relocation; refuses while container ownership is unresolved |
chio process import | Imports an exported host; the source stays retired |
Run the administrative commands while the host is stopped. revoke-capability calls the kernel's revoke_capability, records the revocation in the host's authority.db under --state, and prints the revoked capability_id. cancel calls ProcessRuntime::cancel. The same revoke and cancel commands serve day-to-day connection and cancellation work in process operations.
A revoked credential fails in WorkerService::authenticate (chio-process/src/worker.rs:133-144), which runs at line 154, one line before the operation match at line 155, and returns {"ok": false, "error": {"code": "unauthenticated"}}. A cancelled process returns {"ok": false, "error": {"code": "cancelled"}}.
Host stores bound to --state
chio process refuses the global store and trust-control options with "process host stores are bound to --state; global store and control URL overrides are unsupported". The trust commands reach a different store: chio trust revoke and chio trust status pointed at the host's authority.db with --revocation-db print error[CHIO-CLI-REVOCATION-STORE]: revocation store synchronization error: the provisioned SQLite authority store requires its joint serving owner at epoch <n>, with help: revoke through the serving process host with chio process revoke-capability --state <dir>, and exit 1, whether or not the host is running.
Pointed at any other file, chio trust revoke records a revocation the process host never reads, and the process's next call is still allowed. Use chio process revoke-capability for a process host.
Local receipt reads
Both reading commands fail closed. Without a receipt store, receipt list refuses with "receipt commands require --receipt-db <path> or --control-url" and receipt explain with "receipt explain requires --input-file, --receipt-db, or --control-url". Without an administrative or tenant scope, both refuse with "--tenant <id> or --admin-all is required for local receipt reads".