Chio/Docs
LOGIN · JOIN

BuildConnect

Wrap an ACP Server

Embed Chio with an ACP agent to evaluate JSON-RPC methods and record tool-call events.

This is a library crate, not a CLI

The ACP proxy ships as the chio-acp-proxy Rust crate. The CLI carries no chio acp subcommand: you wrap an ACP agent by embedding the crate and driving AcpProxy from your own binary. The JSON-RPC interceptor, the built-in FsGuard and TerminalGuard, and the unsigned audit-entry path come up through AcpProxy::start; kernel-backed receipt signing and capability-token checking are wired in through AcpProxy::start_with_kernel. The one CLI command in this workflow is chio cert, which turns a session's receipts into a compliance certificate.

Why Wrap ACP

ACP is the Agent Client Protocol used by coding agents and IDE sidecars: editors talk to agents over JSON-RPC 2.0 on stdio, and the agent exposes methods like session/prompt, session/request_permission, fs/read_text_file, fs/write_text_file, and terminal/create. It is a message-based protocol, not a tool-call-based one. The agent drives the session and emits session/update notifications that carry tool-call events as they happen.

ACP changes where Chio can observe actions. With MCP, Chio intercepts a symmetric request-response pair for every tool use. With ACP, Chio intercepts ongoing bidirectional traffic and promotes the observed tool-call events inside session updates into audit entries. Wrapping an ACP agent with Chio gives you:

  • Transparent ACP proxying. The editor still speaks ACP. The agent still speaks ACP. Chio is transparent on the wire.
  • Filesystem and terminal guards on fs/read_text_file, fs/write_text_file, and terminal/create. In ACP the agent calls back into the editor for those, so the guards run on agent-to-editor traffic, before the editor acts.
  • An audit trail of tool-call events observed in session updates, each with a SHA-256 content hash and the session, tool-call, and server identifiers already filled in.
  • A promotion path to signed receipts so unsigned ACP audit entries can be cross-linked with MCP and A2A receipts in a single compliance query.

Prerequisites

You need three things:

  1. An upstream ACP agent. Any agent that implements ACP over stdio JSON-RPC works. Examples below use a Claude-style coding agent binary invoked as claude-code, but the adapter is agent-agnostic: the proxy spawns whatever command you give it and pipes JSON-RPC through.
  2. Guard allowlists: the set of filesystem path prefixes the agent may read or write, and the terminal commands it may launch through ACP's terminal interface. These are plain string lists you set on AcpProxyConfig, not a policy document.
  3. A kernel signer and capability checker, optionally. Pass a ReceiptSigner and a CapabilityChecker to start_with_kernel to promote ACP audit entries into signed Chio receipts and to gate terminal lifecycle calls. Without them, AcpProxy::start still enforces the built-in guards and produces unsigned audit entries.

ACP guards are not HushSpec

The MCP wrap path compiles a HushSpec policy. The ACP proxy does not: AcpProxyConfig has no policy-file ingestion at all. Its FsGuard and TerminalGuard are constructed directly from the plain path-prefix and command lists you supply on the builder. An MCP path_allowlist or shell_commands block does not port over. The two crates share no policy-loading mechanism.

Embed the Proxy

You wrap an ACP agent from Rust. Add the crate, build an AcpProxyConfig, and start an AcpProxy. The two required inputs are the agent command and the public key used to verify receipts. Builder methods add the path prefixes and commands the agent may use; with_allowed_path_prefix and with_allowed_command are both repeatable.

Cargo.tomltoml
[dependencies]
chio-acp-proxy = "0.1"
anyhow = "1"
src/main.rsrust
use chio_acp_proxy::{AcpProxy, AcpProxyConfig};

fn main() -> anyhow::Result<()> {
    // AcpProxyConfig::new(agent_command, public_key). All guard lists
    // start empty (deny-all); each builder call widens one of them.
    let config = AcpProxyConfig::new("claude-code", server_public_key_hex())
        .with_agent_args(vec!["--stdio".into()])
        .with_server_id("srv-coder")
        // Filesystem prefixes the agent may read or write (FsGuard).
        .with_allowed_path_prefix("/home/dev/project")
        // Terminal commands the agent may launch (TerminalGuard).
        .with_allowed_command("cargo")
        .with_allowed_command("git");

    // Standalone: built-in FsGuard + TerminalGuard only. No signer, no
    // capability checker -- audit entries are unsigned, and terminal
    // lifecycle calls (terminal/kill, terminal/release) are denied outright.
    let proxy = AcpProxy::start(config)?;
    run_stdio_loop(proxy)
}

For signed receipts and live capability checks, start the proxy with a kernel-backed ReceiptSigner and CapabilityChecker. The crate ships KernelReceiptSigner and KernelCapabilityChecker for this. The third argument sets the attestation mode: BestEffort (the default) logs signing failures but lets operations proceed; Required fails a call closed if its receipt cannot be signed.

src/kernel_backed.rsrust
use chio_acp_proxy::{AcpAttestationMode, AcpProxy};

// signer: impl ReceiptSigner, checker: impl CapabilityChecker -- both
// backed by your kernel (KernelReceiptSigner / KernelCapabilityChecker).
let proxy = AcpProxy::start_with_kernel(
    config,
    Some(Box::new(signer)),        // promote audit entries into signed receipts
    Some(Box::new(checker)),       // gate terminal lifecycle + capability checks
    AcpAttestationMode::Required,  // signing failures fail closed
)?;

server_id vs. the session capability id

with_server_id (default chio-acp-proxy) writes a stable identity string into audit entries and receipts, which compliance queries use to join ACP evidence with MCP and A2A receipts. Separately, a receipt's capability id is the id of whatever capability authorized the operation. When nothing did, the signer synthesizes acp-session:<session_id> from the ACP session instead. That synthesized form is the prefix chio cert generate matches on, which is a narrower net than it looks. Rotate server_id only alongside the signing key so you do not fragment the evidence chain.

How ACP Flows Through Chio

The proxy treats the wire as a symmetric JSON-RPC stream. The editor writes requests and notifications into Chio; Chio reads each one, routes it by method, and decides whether to forward, block, or forward-with-attestation. Direction matters here more than it does in MCP: every guarded method is intercepted only on the agent-to-editor leg, because in ACP it is the agent that asks the editor to read a file or start a terminal.

rendering
The proxy sits between editor and agent and routes every message by method. Guarded methods travel agent to editor, so the guards run before the editor acts on them.

Method by method, here is what the interceptor does. Names match the AcpMethod discriminator the proxy parses from every request.

ACP methodWhat Chio does
initialize, authenticateForwarded unchanged. The proxy neither guards nor records the handshake.
session/new, session/load, session/list, session/set_config_option, session/set_modeForwarded unchanged. The proxy keeps no session registry; the session id it writes onto an entry comes from the message that carried it.
session/promptForwarded unchanged. There is no prompt guard and no prompt record: what the agent decides to do with the prompt shows up later, as tool-call events.
session/request_permissionInterposed on the agent-to-editor leg. The four ACP permission options map to Chio allow/deny decisions, with an unknown option mapping to deny. The mapping is logged, not written to an audit entry, and the editor's own UI still makes the actual choice.
fs/read_text_file, fs/write_text_fileGuarded by FsGuard. The path must be absolute, and .. traversal is rejected before anything else. The path is then canonicalized, so a symlink is followed and judged on its target. Reads and writes check the same prefix set.
terminal/createCapability-checked first when a CapabilityChecker is installed, then guarded by TerminalGuard: the command must be on the allow list and arguments are checked for shell metacharacters. A cwd parameter goes through FsGuard as well.
terminal/kill, terminal/releaseGated on a CapabilityChecker alone. These route through intercept_terminal_lifecycle, which fails closed: with no checker installed (standalone AcpProxy::start), both are denied outright with a JSON-RPC -32000 access-denied error, because no built-in guard covers process lifecycle. With a checker, they require a parameter-bound Chio receipt naming the session and terminal.
terminal/output, terminal/wait_for_exitForwarded unchanged. Reading output and waiting for exit on an already-allowed terminal is not guarded again.
session/cancelForwarded, and it clears state. In both directions it purges the session's pending capability contexts and every live context bound to it, so authorization material from a cancelled turn cannot be reused by the next one.
session/update (notification)Observed. Tool-call events (ToolCall, ToolCallUpdate) are parsed, hashed, and emitted as audit entries or signed receipts.

Guard failures are returned as JSON-RPC errors on the original request id, using the server error code -32000. The message names the operation class and the value that failed, not the guard: access denied: fs read denied for path: /etc/shadow, access denied: terminal denied: command not allowed: rm, access denied: terminal denied: suspicious argument: $(whoami). Traversal is its own shape, path traversal detected: {path}, and names neither. The editor surfaces all of them as ordinary tool errors, so no client-side changes are required.


Configuring the Guards

The proxy has two built-in guards. Both are constructed directly from the plain allowlists on AcpProxyConfig; there is no policy document and no glob or regex grammar to learn.

  • FsGuard: path prefixes from with_allowed_path_prefix, enforced on fs/read_text_file and fs/write_text_file. It is fail-closed: an empty path is denied, a relative path is denied, an empty prefix set denies everything, and a path containing .. is rejected before any prefix is consulted. What survives that is canonicalized through the filesystem, so a symlink is resolved and then judged on its target, which is what stops a link out of the tree. Prefix matches land on path boundaries, so /home/dev/project never matches /home/dev/project_evil.
  • TerminalGuard: command allowlist from with_allowed_command, enforced on terminal/create. Also fail-closed: an empty command is denied, an empty allow list denies everything, the command must be an exact string match in the list, and arguments carrying any of six shell metacharacters are rejected as defense-in-depth. The six are a backtick, the two-character sequence $(, |, ;, a newline, and a carriage return. The proxy launches through execve rather than a shell, so none of these would be interpreted anyway; the check is there so a command that later reaches a shell cannot be smuggled through an argument.
src/guards.rsrust
let config = AcpProxyConfig::new("claude-code", public_key)
    .with_agent_args(vec!["--stdio".into()])
    .with_server_id("srv-coder")
    // FsGuard prefix set. One list covers both reads and writes, so scope
    // it to what the agent may WRITE, not just what it may read.
    .with_allowed_path_prefix("/home/dev/project")
    // TerminalGuard allowlist. Build, test, and VCS only -- everything
    // else denies at terminal/create.
    .with_allowed_command("cargo")
    .with_allowed_command("git")
    .with_allowed_command("rg");

Two patterns are worth calling out. Keep the prefix set as narrow as the workflow allows. Because reads and writes share it, a wide prefix grants broad write access. Keep with_allowed_command to the minimum set an agent needs; the metacharacter check catches pipe-to-shell tricks smuggled through arguments. The allowlist defines the command boundary.

Neither built-in guard covers process lifecycle. To govern terminal/kill and terminal/release, install a CapabilityChecker through start_with_kernel; without one they are denied outright.


Receipts for ACP Messages

A session/update notification carrying a ToolCall event produces an audit entry, and so does a ToolCallUpdate that carries a status. A status-less update produces nothing. When a kernel-backed ReceiptSigner is installed, the entry is also promoted into a Chio receipt signed with the kernel's Ed25519 key.

This is the entry shape, from the crate:

crates/protocol/chio-acp-proxy/src/receipt.rs60-99rust
pub struct AcpToolCallAuditEntry {
    pub tool_call_id: String,
    pub title: String,
    pub kind: Option<String>,
    pub status: String,
    pub session_id: String,
    /// Seconds since the Unix epoch (UTC).
    pub timestamp: String,
    pub server_id: String,
    /// SHA-256 hex digest of the canonical JSON representation of
    /// the originating tool-call event.
    pub content_hash: String,
    /// The capability ID that authorized the live operation, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub capability_id: Option<String>,
    /// The authoritative Chio receipt emitted during the live authorization check.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorization_receipt_id: Option<String>,
    /// Kernel request id that produced `authorization_receipt_id`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorization_request_id: Option<String>,
    /// ACP tool-call id signed into the live authorization receipt.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorization_tool_call_id: Option<String>,
    /// Correlation id signed into the live authorization receipt.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorization_correlation_id: Option<String>,
    /// ACP operation signed into the live authorization receipt.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorization_operation: Option<String>,
    /// ACP resource signed into the live authorization receipt.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorization_resource: Option<String>,
    /// Canonical SHA-256 hash of the full authorized ACP parameters.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorization_parameter_hash: Option<String>,
    /// Whether the event was tied to live cryptographic enforcement.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enforcement_mode: Option<AcpEnforcementMode>,
}

Serialization is camelCase, so tool_call_id goes on the wire as toolCallId. Three details in that struct are worth reading twice.

  • timestamp is a String, not a number. It holds seconds since the Unix epoch, rendered as decimal digits, so the JSON reads "timestamp": "1744993921".
  • contentHash does not cover the whole event. It is the SHA-256 of the canonical JSON of the event's declared ACP fields only: toolCallId, title, kind and status for a ToolCall, and toolCallId plus status for an update. Any extra keys the agent sends are excluded on purpose, so an unrecognized field cannot move the digest.
  • The eight authorization* fields are set together or not at all. They arrive as a group when a live capability check authorized the operation, which means an entry that shows one of them shows all eight.

enforcementMode has exactly two values. cryptographically_enforced means a live capability check allowed the underlying operation and the proxy bound that authorization to this event. audit_only means the proxy observed the event without one. The second is not only the no-checker case: filesystem and terminal requests carry no ACP tool-call id, so an enforced context is buffered per session and bound later by matching the update event's kind. If zero or more than one pending context matches, the binding is refused and the entry drops to audit_only rather than guessing.

What the Signed Receipt Carries

With a signer installed, the entry becomes a ChioReceipt. Two fields on it are decided by the enforcement mode and nothing else:

crates/protocol/chio-acp-proxy/src/kernel_signer.rs474-485rust
let (decision, trust_level, semantics) = match enforcement_mode {
    AcpEnforcementMode::AuditOnly => (
        None,
        chio_core::receipt::kinds::TrustLevel::Verified,
        chio_core::receipt::metadata::ReceiptSemanticFields::trace_detect_only(),
    ),
    AcpEnforcementMode::CryptographicallyEnforced => (
        Some(Decision::Allow),
        chio_core::receipt::kinds::TrustLevel::Mediated,
        chio_core::receipt::metadata::ReceiptSemanticFields::mediated_prevent(),
    ),
};

So an audit_only entry signs as a trace observation with no decision at all and trust_level: verified, while a cryptographically_enforced one signs as a mediated decision carrying allow and trust_level: mediated. There is no third arm. The ACP signer constructs no deny decision on any path: a blocked request never reaches the agent, so no session/update describes it, so nothing is signed. A guard denial is a JSON-RPC error and a log line, and that is all it is. Plan your evidence accordingly, and read the section on certificates with that in mind.

The rest of the receipt is built here. This is the whole body, so it is also the complete list of what an ACP receipt does and does not carry:

crates/protocol/chio-acp-proxy/src/kernel_signer.rs499-572rust
let body = ChioReceiptBody {
    // `ChioReceipt::sign` replaces this with the canonical
    // content-addressed receipt id (`chio_receipt_id`), so the literal
    // value here is only a pre-signing id for debug logging. The actual
    // per-event uniqueness flows through `action.parameters` (status,
    // title, kind, content_hash) and through `content_hash`, both of
    // which the canonical id input pulls in. Encode the discriminator
    // into the pre-signing id so tracing logs and any code path that
    // inspects the pre-signing body can tell `running` and
    // terminal `tool_call_update` events apart, and so a future change
    // that bypasses content-addressing does not silently collide on
    // `acp-{tool_call_id}`.
    id: format!(
        "acp-{}-{}-{}",
        entry.tool_call_id, entry.status, entry.content_hash
    ),
    timestamp,
    capability_id: entry
        .capability_id
        .clone()
        .unwrap_or_else(|| format!("acp-session:{}", entry.session_id)),
    tool_server: request.tool_server.clone(),
    tool_name: request.tool_name.clone(),
    action,
    decision,
    receipt_kind: semantics.receipt_kind,
    boundary_class: semantics.boundary_class,
    observation_outcome: semantics.observation_outcome,
    tool_origin: semantics.tool_origin,
    redaction_mode: semantics.redaction_mode,
    actor_chain: semantics.actor_chain.clone(),
    content_hash: entry.content_hash.clone(),
    policy_hash: String::new(),
    evidence: Vec::new(),
    metadata: Some(serde_json::json!({
        "acp": {
            "sessionId": entry.session_id,
            "toolCallId": entry.tool_call_id,
            "capabilityId": entry.capability_id,
            "authorizationReceiptId": entry.authorization_receipt_id,
            "authorizationRequestId": entry.authorization_request_id,
            "authorizationToolCallId": entry.authorization_tool_call_id,
            "authorizationCorrelationId": entry.authorization_correlation_id,
            "authorizationOperation": entry.authorization_operation,
            "authorizationResource": entry.authorization_resource,
            "authorizationParameterHash": entry.authorization_parameter_hash,
            "enforcementMode": enforcement_mode,
        },
        // Mirror the live authorization request id at the canonical
        // `receipt_context.request_id` path so downstream verifiers can
        // reconstruct the request-receipt linkage uniformly across the
        // authorization receipt (written by the kernel) and the
        // consumer receipt (written here). When the entry was promoted
        // through a cryptographically enforced check, this matches the
        // `metadata.receipt_context.request_id` already on the
        // referenced authorization receipt.
        "receipt_context": {
            "request_id": entry.authorization_request_id,
            "session_id": entry.session_id,
            "tool_call_id": entry.tool_call_id,
            "authorization_receipt_id": entry.authorization_receipt_id,
            "authorization_correlation_id": entry.authorization_correlation_id,
            "authorization_operation": entry.authorization_operation,
            "authorization_resource": entry.authorization_resource,
            "authorization_parameter_hash": entry.authorization_parameter_hash,
        }
    })),
    trust_level,
    tenant_id: authorization_context
        .as_ref()
        .and_then(|context| context.tenant_id.clone()),
    kernel_key: self.keypair.public_key(),
    bbs_projection_version: None,
};

Four things follow from that block.

  • policy_hash is empty and evidence is empty. The proxy compiles no policy and runs no kernel guard pipeline, so it has nothing to put in either. A tool that expects every receipt to name a policy will see a blank here.
  • action.parameters is the event, not the ACP request. It holds tool_call_id, title, kind, status and authorization_parameter_hash. There is no top-level path or command key. On the enforced path the original ACP parameters appear one level down, as operation_payload.
  • acp-session:<session_id> is a fallback, not the norm. It is used only when the entry carries no capability id, which is exactly the audit_only case. An enforced receipt carries the presented capability token's own id instead. The two never appear together.
  • The metadata has two objects, cased differently. metadata.acp is camelCase and carries eleven keys. metadata.receipt_context is snake_case and repeats the linkage fields under the canonical path a kernel-written receipt uses, so a verifier can join an ACP receipt to the authorization receipt behind it without knowing which side wrote which.

For the full receipt format and verification procedure, see Receipts.

List the receipts captured for a session with chio receipt list. There is no --protocol flag; scope the read to the ACP server with --tool-server. A local --receipt-db read fails closed unless you pass exactly one of --tenant <id> or --admin-all.

bash
$ chio --receipt-db ./receipts.sqlite receipt list \
    --tool-server srv-coder --admin-all

receipt list emits JSON Lines (one ChioReceipt per line) regardless of any format flag. There is no table renderer. The ACP-specific fields ride under each receipt's metadata.acp object (sessionId, toolCallId, enforcementMode, and the authorization-linkage fields). Pipe to jq to filter or fold the stream into whatever table view you want.


Compliance Certificates

The receipts are the raw evidence; a compliance certificate is the signed summary over one session. Point chio cert generate at a session id and a receipt store; it reads receipts whose capability id starts with acp-session:<session_id> and emits a signed ComplianceCertificate.

That prefix selects the audit-only half

The prefix acp-session: appears on a receipt only when the audit entry had no capability id of its own, which is the audit_only case. A cryptographically enforced receipt carries the presented token's capability id, and a prefix query does not reach it. Certificates are therefore a summary of what the proxy observed, not of what it enforced, unless every enforced receipt in the store happens to have been issued under a capability id sharing that prefix. Two more conditions bind generation: each selected receipt must repeat the session id at metadata.acp.sessionId, and the selected rows must be consecutive in the store, so a receipt database interleaving ACP receipts with MCP or A2A ones will refuse on chain discontinuity. Give an ACP session its own receipt database.
bash
# Generate a signed certificate over one session's receipts.
$ chio cert generate --session-id sess_01HZ7Q8YB3N8G0XHB0T6KQ4F9R \
    --receipt-db ./receipts.sqlite \
    --budget-limit 600 \
    --output ./session-cert.json

# Verify it against the trusted kernel key. Add --full --receipt-db to
# re-verify every underlying receipt signature, not just the certificate's.
$ chio cert verify --certificate ./session-cert.json \
    --trusted-kernel-pubkey ./kernel.pub

# Inspect the body without verifying.
$ chio cert inspect --certificate ./session-cert.json

--session-id and --receipt-db are both required on generate; with --output and no --json it writes the file and prints nothing to stdout. Generation is fail-fast: it produces a certificate or it produces an error, never a certificate recording a failure. Point it at a session with no receipts and it says so:

reputation-certs · cert-generate-emptytranscript
$ chio cert generate \
    --session-id demo-session \
    --receipt-db ./receipts.db \
    --authority-seed-file ./authority.seed
error [urn:chio:error:cli:other]: certificate generation failed: empty session: no receipts found for session demo-session
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.
exit 1

chio cert inspect prints the body without checking a signature. Thirteen lines, in this order, plus an Anomalies: block between Guards: and Signer key: when there are any:

reputation-certs · cert-inspecttranscript
$ chio cert inspect --certificate ./cert.json
Session ID:     demo-session
Schema:         chio.compliance.certificate.v1
Issued at:      1756944000
Receipt count:  3
First receipt:  1756943000
Last receipt:   1756944000
Signatures:     valid
Chain:          continuous
Scope:          compliant
Budget:         compliant
Guards:         compliant
Signer key:     31debe55d37c722768b137131caa6087080b2e0b60b94bd785d14575cfa498bc
Kernel key:     31debe55d37c722768b137131caa6087080b2e0b60b94bd785d14575cfa498bc
exit 0

Five of those lines are per-dimension verdicts, and each has a failing spelling: INVALID, BROKEN, VIOLATED, EXCEEDED, BYPASSED. Because generation is fail-fast, a certificate this CLI produced always prints the five compliant spellings and an empty anomaly list; the failing spellings are what you see when you inspect a certificate that came from somewhere else, or one that has been edited.

chio cert verify prints one line and sets the exit code. --certificate and --trusted-kernel-pubkey are both required. Against a body whose signature does not check out:

reputation-certs · cert-verify-unsignedtranscript
$ chio cert verify \
    --certificate ./cert.json \
    --trusted-kernel-pubkey ./kernel.pub
FAIL: verification failed: certificate signature invalid
exit 1

--full re-verifies every underlying receipt signature rather than the certificate's alone, which needs the receipt store, and refuses without it rather than quietly verifying less:

reputation-certs · cert-verify-full-no-dbtranscript
$ chio cert verify \
    --certificate ./cert.json \
    --trusted-kernel-pubkey ./kernel.pub \
    --full
error [urn:chio:error:cli:other]: full-bundle verification requires --receipt-db
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.
exit 1

The certificate body reports the receipt count, the session's first and last receipt (in store order, not by timestamp), the five per-dimension verdicts, and the signer and kernel keys. It is signed with the kernel's Ed25519 key, so a downstream consumer attests to a whole session without replaying the raw receipts.


Differences from the MCP Adapter

At a glance:

DimensionMCP adapterACP adapter
ShapeRequest/response tool callsBidirectional JSON-RPC message stream
Primary interception pointtools/call requestTyped AcpMethod per message + session/update observation
Tool discoveryProxied tools/listSession-time capability advertisement, typically implicit
Guard coverageThe compiled HushSpec pipeline: forbidden-path, shell-command, egress-allowlist, path-allowlist, mcp-tool, secret-leak, patch-integrityFsGuard (path prefixes), TerminalGuard (command allowlist), capability-checked terminal lifecycle, permission-kind mapping
Receipt triggerEvery tool-call decisionEvery tool-call event observed in a session update. A guard denial is a JSON-RPC error only, so a blocked call leaves no receipt.
Receipt capability idThe presented capability token's own id, copied verbatim. There is no protocol prefix.The same, when a live check authorized the operation. Otherwise a synthesized acp-session:<id>, because an observed event has no token to name.
Standalone (no kernel) modeRefuses to start without a durable session and receipt databaseAcpProxy::start enforces the built-in guards and produces unsigned audit entries; terminal/kill and terminal/release are denied outright
Transportstdio, plus a Streamable HTTP edge through chio mcp serve-httpstdio only: line-based JSON-RPC over a spawned subprocess. Neither ACP crate links an HTTP server.

The asymmetry worth internalizing: MCP signs the decision. ACP signs the observation. An MCP deny is a signed receipt you can query; an ACP deny is an error the editor renders and a line in the proxy log. If your compliance story needs denials to be evidence, put the enforcement boundary where the receipts are.


Verify the Result

Five checks. The proxy is a library, so the first three run inside your binary and the last two run against the receipt store it wrote.

  1. The agent starts behind the proxy. Your editor connects and the session opens as it did before. The proxy is transparent on the wire, so anything visible here is a spawn problem, not a policy one.
  2. A path inside the allowed prefix reads. Ask the agent to open a file under the prefix you passed to with_allowed_path_prefix. It should succeed.
  3. A path outside it does not. Ask for something one directory up. The editor should surface access denied: fs read denied for path: .... This is the deny to test for, and it is the one that leaves no receipt, so confirm it at the editor rather than in the store.
  4. Entries land, with the mode you expect. Read the store back with chio --receipt-db <path> receipt list --tool-server <your server_id> --admin-all and check metadata.acp.enforcementMode on each line. All audit_only where you expected enforcement means the capability context did not bind; see the table below.
  5. A certificate generates. Run chio cert generate for the session. An empty-session error means the prefix matched nothing, which is the expected outcome for a store holding only enforced receipts.

Failures and Recovery

Common failure modes and how to read them from the proxy log.

SymptomLikely causeFix
Editor shows access denied: fs read denied on every fs/read_text_fileNo prefix was supplied, so the empty prefix set denies everything. Or the project root is a symlink: the guard canonicalizes the path first, so the real target is what gets prefix-matchedAdd the canonical absolute path via with_allowed_path_prefix, resolving symlinks yourself first so the two agree
Path traversal error in the logAgent requested a path containing .. segments that escape the allowed prefixThis is working as intended. If legitimate, normalize the path before sending
terminal/create denied for a command that should workCommand not in the TerminalGuard allowlist, or the binary was invoked through a shell wrapperAdd the command via with_allowed_command, or adjust the agent to call the binary directly
Audit entries appear but have enforcementMode: audit_onlyNo CapabilityChecker is installed, or the session has no bound capability token, so the proxy only observed the eventStart the proxy with start_with_kernel and a CapabilityChecker so the operation is capability-checked before it is forwarded
Audit entries are unsignedThe proxy is running via AcpProxy::start, so no ReceiptSigner is installedExpected for standalone mode. For signed receipts, use start_with_kernel with a signer; set AcpAttestationMode::Required to fail closed when signing fails
access denied: terminal denied: suspicious argument on an argument that looks fineThe argument carries one of the six rejected characters. A carriage return from a Windows-authored file is the easy one to missStrip the character. The guard does not care whether a shell is involved, and there is no escape syntax
terminal/kill and terminal/release denied with -32000No CapabilityChecker is installed. Neither built-in guard covers process lifecycle, so the interceptor fails closed rather than forwarding ungovernedInstall a checker through start_with_kernel. There is no allowlist that opens these
A blocked call leaves nothing in the receipt storeWorking as built. A guard denial returns a JSON-RPC error and a tracing::warn line; the request never reaches the agent, so no session/update describes it and nothing is signedCapture the proxy log if denials need to be durable evidence. The receipt store records what happened, not what was refused
cert generate reports an empty session for a session you know produced receiptsThose receipts carry a real capability id, not the acp-session: fallback, and the prefix query does not reach themRead them directly with chio receipt list --tool-server and join on metadata.acp.sessionId
Upstream agent exits immediatelyMisparsed -- boundary, or the agent itself expects arguments the proxy did not forwardCheck the log line spawning ACP agent and run the same command outside chio to confirm it starts cleanly

The mirror direction: chio-acp-edge

The examples/hello-acp walkthrough demonstrates the sibling crate chio-acp-edge, which runs the opposite direction: Chio exposes its own tools as an ACP server instead of wrapping a third-party agent. Its run-edge.sh and smoke.sh exercise session/list_capabilities, tool/invoke, deferred tool/stream, and tool/resume. It shows ACP JSON-RPC request and response shapes, but it is the edge, not the chio-acp-proxy wrap this guide covers.

Summary

Wrapping an ACP agent with Chio gives you:

PropertyWhat Chio adds
Guard enforcementFsGuard path-prefix allowlist, TerminalGuard command allowlist, capability-checked terminal lifecycle. Enforcement only: a denial is a JSON-RPC error, not a receipt
AuditabilityEvery tool-call event, and every status-bearing update, recorded with a SHA-256 hash over its declared ACP fields
AttestationAudit entries promotable to signed Chio receipts, each carrying enforcementMode so a consumer can tell an observation from an enforcement. AcpAttestationMode::Required fails a call closed rather than letting an unsigned one through
TransparencyEditor and agent keep speaking ACP unchanged; the proxy is a subprocess boundary with no client SDK to adopt
Cross-protocol joinsACP receipts share the same receipt store and key material as MCP and A2A receipts; one query covers all three

Next Steps

  • Wrap an MCP Server · the sibling guide for tool-call-based servers. Worth reading back-to-back with this one.
  • Write a Policy · HushSpec reference for the guards mentioned above.
  • Receipts · receipt format, verification, and the attestation-gap states ACP introduces.
  • Native Tool Server · when you want the agent to speak Chio directly instead of sitting behind an ACP adapter.