Chio/Docs
LOGIN · JOIN

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.

Guide chio@5f63e6e

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.

Start
Withdrawal controlsChoose a scenario.
credentialrevokeadmissioncancelcapabilityrevoke-capabilitysignsapKdρworkerprocess runtimekerneleffectsmailboxno receiptresponse"code": "unauthenticated"credentialrevokeadmissioncancelcapabilityrevoke-capabilitysignsapKdρworkerprocess runtimekerneleffects mailboxno receiptresponse"code": "unauthenticated"

With 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.

One worker call on a process host, in the order the worker service, the process runtime, and the kernel check it, for the five scenarios of the containment example. Spacing carries no duration; the policy's guards, budget shares, and descendant processes are left out.

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.

Terminalbash
CHIO_STATE=/var/lib/larkfield/pricing-host
CHIO_PROCESS=fixer
CHIO_SOCKET=/run/larkfield/pricing-host.sock
CHIO_INCIDENT=/var/lib/larkfield/incidents/pr-421

Keep 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.

Terminalbash
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 revoke removes the process's bearer credentials, so its next call fails authentication.
  • chio process revoke-capability records a durable kernel revocation of the process's capability and every descendant in its delegation subtree.
  • chio process cancel permanently 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.

Terminalbash
chio new security-containment --example security-containment
cd security-containment
python3 run.py
Example outputtext
ALLOW 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 receipts

You 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 appliedWorker responseReceipt
Capability revoked"ok": true, verdict denyKernel-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.

run.pypython
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.

host-config.jsonjson
{
  "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"}]
    }
  ]
}
policy.yamlyaml
kernel:
  max_capability_ttl: 3600
  delegation_depth_limit: 1
  durable_admission_mode: all
capabilities:
  default:
    tools:
      - server: chio-ipc
        tool: '*'
        operations: [invoke, delegate]
        ttl: 3600

6. 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.

Terminalbash
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-all

You 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.

Terminalbash
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.

Process administration commands

CommandEffect
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-capability
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".

Next: connect the withdrawal to the operations it interrupted