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
chio-acp-proxy Rust crate. There is no chio acp subcommand today: 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 CLI command in this workflow is chio 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 that apply to
fs/read_text_file,fs/write_text_file, andterminal/createbefore the agent sees them. - 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.
What You Need
You need three things:
- 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. - 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. - A kernel signer and capability checker, optionally. Pass a
ReceiptSignerand aCapabilityCheckertostart_with_kernelto promote ACP audit entries into signed Chio receipts and to gate terminal lifecycle calls. Without them,AcpProxy::startstill enforces the built-in guards and produces unsigned audit entries.
ACP guards are not HushSpec
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 today.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.
[dependencies]
chio-acp-proxy = "0.1"
anyhow = "1"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.
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, each receipt's capability id defaults to acp-session:<session_id>, derived from the ACP session — that is the prefix chio cert generate uses to pull receipts belonging to one session. 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. Agent-to-editor messages are handled the same way in reverse.
Method by method, here is what the interceptor does. Names match the AcpMethod discriminator the proxy parses from every request.
| ACP method | What Chio does |
|---|---|
initialize, authenticate | Forward unchanged. chio records the session handshake but does not guard it. |
session/new, session/load, session/list | Forward. Session identifiers are captured for later correlation with tool-call events. |
session/prompt | Forwarded. The prompt is recorded for correlation with later tool-call events; the proxy adds no content guard of its own here. |
session/request_permission | Interpose. ACP permission kinds are mapped to Chio capability decisions for the audit log; the editor's own UI still makes the actual allow/deny choice. |
fs/read_text_file, fs/write_text_file | Guarded by FsGuard. The path must be absolute and fall under an allowed prefix; .. traversal and symlinks that escape the allowed tree are rejected before the prefix check. Reads and writes check the same prefix set. |
terminal/create | Guarded by TerminalGuard. The command must be on the allow list, and arguments are checked for shell-injection metacharacters. When a CapabilityChecker is installed, the request is capability-checked too. |
terminal/kill, terminal/release | Gated 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_exit | Forwarded unchanged. Reading output and waiting for exit on an already-allowed terminal is not guarded again. |
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 with a message that names the guard. The editor surfaces those 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 fromwith_allowed_path_prefix, enforced onfs/read_text_fileandfs/write_text_file. It is fail-closed: the path must be absolute and fall under an allowed prefix, an empty prefix set denies everything, and..traversal or symlinks escaping the allowed tree are rejected before the prefix check. Prefix matches land on path boundaries, so/home/dev/projectnever matches/home/dev/project_evil.TerminalGuard— command allowlist fromwith_allowed_command, enforced onterminal/create. Also fail-closed: the command must be an exact match in the list, and arguments carrying shell-injection metacharacters (backtick,$(),|,;, newlines) are rejected as defense-in-depth.
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
Every session/update notification that carries a tool-call event produces an audit entry. The entry records the tool-call id, title, kind, status, session id, server id, a Unix timestamp, and a SHA-256 hex digest of the canonical JSON of the originating event. When a kernel-backed ReceiptSigner is installed, the entry is also promoted into a Chio receipt signed with the kernel's Ed25519 key.
The audit-entry shape the proxy emits before signing looks like this:
{
"toolCallId": "tc_01HZ7Q8YB3N8G0XHB0T6KQ4F9R",
"title": "edit src/main.rs",
"kind": "edit",
"status": "completed",
"sessionId": "sess_01HZ7Q8YB3N8G0XHB0T6KQ4F9R",
"timestamp": "1744993921",
"serverId": "srv-coder",
"contentHash": "8f3b2a4e9c1d0f7b6a5e2c3d4f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a",
"capabilityId": "acp-session:sess_01HZ7Q8YB3N8G0XHB0T6KQ4F9R",
"authorizationReceiptId": "rcpt-acp-01HZ7Q8YB3N8G0XHB0T6KQ4F9R",
"enforcementMode": "cryptographically_enforced"
}A few fields are ACP-specific:
toolCallIdis the ACP protocol's own stable id for the event. It is stable acrossToolCalland its subsequentToolCallUpdatestatus transitions, so you can fold related entries together downstream.capabilityIdtakes the formacp-session:<session_id>when the entry carries no more specific capability id.chio cert generate --session-idmatches on that prefix to pull every receipt that belongs to one session.enforcementModeiscryptographically_enforcedwhen a live capability check allowed the underlying operation before the event was forwarded, oraudit_onlywhen the proxy merely observed the event.
When the kernel-backed signer is active, the audit entry is wrapped into a ChioReceipt whose ACP fields nest under metadata.acp (sessionId, toolCallId, capabilityId, enforcementMode, and the authorization-linkage fields), with the capability id defaulting to acp-session:<session_id>. An audit_only entry signs as a trace/observation receipt; a cryptographically_enforced one carries a mediated allow decision. Either way it lands in the same receipt store as MCP and A2A receipts. 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.
$ chio --receipt-db ./receipts.sqlite receipt list \
--tool-server srv-coder --admin-all
{"id":"rcpt-acp-01HZ7Q8YB...","timestamp":1744993863,"capability_id":"acp-session:sess_01HZ7Q8YB...","tool_server":"srv-coder","tool_name":"fs/read_text_file","action":{"parameters":{"path":"/home/dev/project/src/main.rs"},"parameter_hash":"a3c8a200..."},"decision":{"verdict":"allow"},"content_hash":"8f3b2a4e...","policy_hash":"...","metadata":{"acp":{"sessionId":"sess_01HZ7Q8YB...","toolCallId":"tc_01HZ7Q8YB...","enforcementMode":"cryptographically_enforced"}},"kernel_key":"25403c1e...","signature":"a3b4c5d6..."}
{"id":"rcpt-acp-01HZ7Q8YC...","timestamp":1744993865,"capability_id":"acp-session:sess_01HZ7Q8YB...","tool_server":"srv-coder","tool_name":"fs/write_text_file","action":{"parameters":{"path":"/home/dev/project/src/main.rs"},"parameter_hash":"b71c9d04..."},"decision":{"verdict":"allow"},"content_hash":"5c1a77e2...","policy_hash":"...","metadata":{"acp":{"sessionId":"sess_01HZ7Q8YB...","toolCallId":"tc_01HZ7Q8YC...","enforcementMode":"cryptographically_enforced"}},"kernel_key":"25403c1e...","signature":"e7f8a9b0..."}
{"id":"rcpt-acp-01HZ7Q8YD...","timestamp":1744993869,"capability_id":"acp-session:sess_01HZ7Q8YB...","tool_server":"srv-coder","tool_name":"terminal/create","action":{"parameters":{"command":"rm"},"parameter_hash":"9f0e12a3..."},"decision":{"verdict":"deny","reason":"command 'rm' not in allow list","guard":"terminal-guard"},"content_hash":"3d2b90cc...","policy_hash":"...","metadata":{"acp":{"sessionId":"sess_01HZ7Q8YB...","toolCallId":"tc_01HZ7Q8YD..."}},"kernel_key":"25403c1e...","signature":"c1d2e3f4..."}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. Generate it with the chio CLI command. 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.
# 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.jsonSession ID: sess_01HZ7Q8YB3N8G0XHB0T6KQ4F9R
Schema: chio.compliance.certificate.v1
Receipt count: 4
First receipt: 1744993921
Last receipt: 1744993932
Signatures: valid
Chain: continuous
Scope: compliant
Budget: compliant
Guards: compliantThe certificate body reports the receipt count, the session's first and last receipt timestamps, and per-dimension verdicts — signatures valid, chain continuous, scope compliant, budget compliant, guards compliant — plus any anomalies. 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:
| Dimension | MCP adapter | ACP adapter |
|---|---|---|
| Shape | Request/response tool calls | Bidirectional JSON-RPC message stream |
| Primary interception point | tools/call request | Typed AcpMethod per message + session/update observation |
| Tool discovery | Proxied tools/list | Session-time capability advertisement, typically implicit |
| Guard coverage | mcp-tool, path-allowlist, forbidden-path, shell, egress, secret, patch, velocity | FsGuard (path prefixes), TerminalGuard (command allowlist), capability-checked terminal lifecycle, permission-kind mapping |
| Receipt trigger | Every tool-call decision | Every tool-call event observed in a session update, plus guarded fs and terminal decisions |
| Capability-ID prefix | mcp-server:<id> | acp-session:<id> |
| Standalone (no kernel) mode | Produces unsigned decisions if seed absent | AcpProxy::start enforces the built-in guards and produces unsigned audit entries; terminal/kill and terminal/release are denied outright |
| Transport | stdio today, HTTP edge available | stdio today, line-based JSON-RPC; HTTP edge planned |
MCP guards are evaluated at decision time, and their allow or deny decisions are signed with the tool call. ACP guards are also evaluated at decision time; session/update events add tool-call observations afterward. ACP receipts share asession_id, so certificates are session-scoped.
Troubleshooting
Common failure modes and how to read them from the proxy log.
| Symptom | Likely cause | Fix |
|---|---|---|
Editor shows access denied on every fs/read_text_file | No prefix supplied via with_allowed_path_prefix, or the project root is symlinked outside the allowed prefix | Add the canonical absolute path via with_allowed_path_prefix, resolving symlinks first |
| Path traversal error in the log | Agent requested a path containing .. segments that escape the allowed prefix | This is working as intended. If legitimate, normalize the path before sending |
terminal/create denied for a command that should work | Command not in the TerminalGuard allowlist, or the binary was invoked through a shell wrapper | Add the command via with_allowed_command, or adjust the agent to call the binary directly |
Audit entries appear but have enforcementMode: audit_only | No CapabilityChecker is installed, or the session has no bound capability token, so the proxy only observed the event | Start the proxy with start_with_kernel and a CapabilityChecker so the operation is capability-checked before it is forwarded |
| Audit entries are unsigned | No ReceiptSigner is installed — the proxy is running via AcpProxy::start | Expected for standalone mode. For signed receipts, use start_with_kernel with a signer; set AcpAttestationMode::Required to fail closed when signing fails |
| Upstream agent exits immediately | Misparsed -- boundary, or the agent itself expects arguments the proxy did not forward | Check the log line spawning ACP agent and run the same command outside chio to confirm it starts cleanly |
The mirror direction: chio-acp-edge
examples/hello-acp walkthrough demonstrates the sibling crate, chio-acp-edge — the opposite direction, where 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:
| Property | What Chio adds |
|---|---|
| Guard enforcement | FsGuard path-prefix allowlist, TerminalGuard command allowlist, capability-checked terminal lifecycle, permission-kind mapping |
| Auditability | Every tool-call event observed in session/update recorded with a SHA-256 content hash |
| Attestation | Audit entries promotable to signed Chio receipts; sessions carry explicit attestation status so compliance consumers can reject gaps |
| Transparency | Editor and agent keep speaking ACP unchanged; the proxy is a subprocess boundary with no client SDK to adopt |
| Cross-protocol joins | ACP 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.