Chio/Docs
LOGIN · JOIN

Wrap an ACP Server

Embed the ACP proxy in your host so each file and terminal request an agent sends to the editor is checked before the editor acts.

Guide chio@5f63e6e

On this page

In ACP the agent asks the editor to read files, write files, and start terminals. With chio-acp-proxy between them, each of those requests passes the proxy's guards, and a kernel capability check when you install one, before the editor acts.

The editor and the agent keep speaking ACP over stdio. You embed the proxy crate in the Rust binary that spawns the agent; tool-call events the agent reports become audit entries, and signed receipts once a kernel signer is installed. chio cert then summarizes a session into a signed compliance certificate.

Start

The proxy checks requests on their way to the editor

ACP, the Agent Client Protocol, connects editors and IDE sidecars to coding agents over JSON-RPC 2.0 on stdio. It is message-based: the agent drives the session, calls back into the editor with methods like fs/read_text_file, fs/write_text_file, and terminal/create, and reports its tool calls in session/update notifications.

ACP proxyChoose an outcome.

κcapabilityσscopegguardsBbudget

A guarded request reaches the editor

The proxy intercepts guarded methods on the agent-to-editor leg, because in ACP the agent asks the editor to read a file or start a terminal. The kernel checks the capability and FsGuard, which rejects relative paths and .. traversal and judges a symlink by its target, then the proxy forwards the request and the receipt is signed. Wrap an ACP Server.

One guarded ACP request from the upstream agent to the editor, intercepted on the agent-to-editor leg. Methods the proxy forwards unchanged, such as initialize and session/prompt, are left out.

Guarded methods are intercepted on the agent-to-editor leg. With a kernel-backed checker installed, the capability check runs first; then FsGuard rejects relative paths and .. traversal and judges a symlink by its target, and TerminalGuard checks the command. A refused request goes back to the agent as JSON-RPC error -32000, and the editor never receives it. A capability refusal is signed as its own admission record; a built-in guard refusal is recorded in the proxy's error and log.

On the return path the proxy observes each session/update carrying a tool-call event, hashes it, and appends an audit entry, which a kernel signer promotes into a signed receipt before the update reaches the editor with Chio metadata attached.

1. Add the proxy to your host

The proxy is a Rust library you drive from your own binary. Add it from the distribution repository.

Terminalbash
cargo add chio-acp-proxy --git https://github.com/backbay-labs/chio
Output
    Updating git repository `https://github.com/backbay-labs/chio`
      Adding chio-acp-proxy (git) to dependencies

You should see the crate added as a git dependency. Your host supplies the agent's executable and ACP launch arguments, the trusted public key for receipts, the canonical project root, and the editor's bidirectional I/O.

Before you begin

Use Rust 1.94 or newer. Any agent that implements the supported ACP methods over stdio JSON-RPC can sit behind the proxy, which spawns its command and pipes JSON-RPC through. Decide two allowlists: the filesystem path prefixes the agent may read or write, and the terminal commands it may launch through ACP's terminal interface.

2. Start the proxy with its allowlists

Build an AcpProxyConfig from the agent command and the public key that verifies receipts, then add path prefixes and commands. with_allowed_path_prefix and with_allowed_command are both repeatable.

src/proxy.rs · host integrationrust
use chio_acp_proxy::{AcpProxy, AcpProxyConfig, AcpProxyError};

pub fn start_proxy(
    agent_command: &str,
    agent_args: Vec<String>,
    public_key: &str,
    project_root: &str,
) -> Result<AcpProxy, AcpProxyError> {
    // AcpProxyConfig::new(agent_command, public_key). All guard lists
    // start empty (deny-all); each builder call widens one of them.
    let config = AcpProxyConfig::new(agent_command, public_key)
        .with_agent_args(agent_args)
        .with_server_id("workspace")
        // Filesystem prefixes the agent may read or write (FsGuard).
        .with_allowed_path_prefix(project_root)
        // 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.
    AcpProxy::start(config)
}

Route editor messages through process_client_message and agent messages through process_agent_message. Forward permitted messages in their original direction, send a Block response back to its sender, and retain the entries returned by ForwardWithReceipt. Your host owns the I/O loop and shutdown.

The guards take plain lists from the builder. HushSpec blocks such as path_allowlist and shell_commands belong to the MCP path; the ACP proxy has no policy-file ingestion and shares no policy loader with it.

3. Install a kernel signer and capability checker

Start the proxy with a ReceiptSigner and a CapabilityChecker to sign receipts and check capabilities live. The crate ships KernelReceiptSigner and KernelCapabilityChecker for this.

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
)?;

The fourth argument sets the attestation mode. BestEffort, the default, logs a signing failure and lets the operation proceed; Required fails the call closed when its receipt cannot be signed. With a checker installed, terminal/kill and terminal/release require a parameter-bound Chio receipt naming the session and terminal.

4. Open one file inside the prefix and one outside it

Connect your editor and open a session the way you did without the proxy. The proxy is transparent on the wire, so a failure here is a spawn problem. Then ask the agent to open a file under the prefix you passed, and one directory above it.

  1. Inside the prefix. The read succeeds.
  2. Outside the prefix. The editor surfaces access denied: fs read denied for path: .... Confirm this refusal at the editor and in the proxy log: a built-in guard refusal blocks the request before any tool-call event exists to sign.

You should see both outcomes as ordinary tool results and errors in the editor, with no client-side change.

5. Read the receipts for the session

List the receipts the proxy wrote, scoped to its server id. A local read names its boundary with exactly one of --tenant <id> or --admin-all.

Terminalbash
chio --receipt-db ./receipts.sqlite receipt list \
    --tool-server workspace --admin-all

You should see JSON Lines, one ChioReceipt per line, with ACP fields under metadata.acp: sessionId, toolCallId, enforcementMode, and the authorization linkage. Check enforcementMode on each line. cryptographically_enforced means a live capability check allowed the operation and the proxy bound that authorization to the event; audit_only means the proxy observed the event without one.

All audit_only where you expected enforcement means the capability context did not bind; the symptoms in the reference below name the causes. Pipe the stream to jq to fold it into the view you need.

The start call decides what a session can prove

The guards run in every mode. What changes is whether events are signed and whether terminal lifecycle calls can pass.

StartChecksterminal/kill, releaseEventsSigning failure
AcpProxy::startFsGuard, TerminalGuardDenied, -32000Unsigned audit entriesNo signer
start_with_kernel, BestEffortGuards and capability checksCapability-checkedSigned receiptsLogged, call proceeds
start_with_kernel, RequiredGuards and capability checksCapability-checkedSigned receiptsCall fails closed

ACP proxy reference

Method handling

The proxy treats the wire as a symmetric JSON-RPC stream and routes each message by the AcpMethod discriminator it parses from the request.

ACP methodProxy actionCheck
initialize, authenticate
Forwarded unchanged
None
session/new, session/load, session/list, session/set_config_option, session/set_mode
Forwarded unchanged
None
session/prompt
Forwarded unchanged
None
session/request_permission
Interposed, agent to editor
Permission-kind mapping
fs/read_text_file, fs/write_text_file
Guarded
FsGuard
terminal/create
Guarded
Capability, then TerminalGuard
terminal/kill, terminal/release
Gated
CapabilityChecker only
terminal/output, terminal/wait_for_exit
Forwarded unchanged
None
session/cancel
Forwarded, clears state
None
session/update (notification)
Observed
Hashed and recorded
  • Handshake and sessions. The proxy keeps no session registry; the session id it writes onto an entry comes from the message that carried it. A prompt has no guard and no record of its own; what the agent does with it shows up later as tool-call events.
  • Permission requests. The four ACP permission options map to Chio allow and deny decisions, and an unknown option maps to deny. The mapping is logged, not written to an audit entry, and the editor's own UI still makes the choice.
  • Files. The path must be absolute, and .. traversal is rejected before anything else. The path is then canonicalized, so a symlink is judged on its target. Reads and writes check the same prefix set.
  • Terminals. terminal/create is capability-checked first when a checker is installed, then TerminalGuard requires an allowed command and rejects shell metacharacters in arguments; a cwd parameter also goes through FsGuard. terminal/kill and terminal/release route through intercept_terminal_lifecycle, which fails closed: with no checker both are denied with -32000, because no built-in guard covers process lifecycle. Output and wait calls on an allowed terminal pass unchecked.
  • Cancel. session/cancel, in either direction, purges the session's pending capability contexts and every live context bound to it, so authorization from a canceled turn cannot be reused by the next one.
  • Updates. ToolCall and ToolCallUpdate events are parsed, hashed, and emitted as audit entries or signed receipts.

Guard failures return on the original request id with server error code -32000. The message names the operation class and the value that failed: 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 has its own shape, path traversal detected: {path}. The editor surfaces each as an ordinary tool error.

Guard allowlists

Both guards are built from plain lists on AcpProxyConfig, with no glob or regex grammar.

  • FsGuard takes prefixes from with_allowed_path_prefix and checks fs/read_text_file and fs/write_text_file. It fails closed: an empty path, a relative path, and an empty prefix set all deny, and a path containing .. is rejected before any prefix is consulted. The surviving path is canonicalized through the filesystem, which stops a symlink out of the tree. Prefixes match on path boundaries, so /home/dev/project never matches /home/dev/project_evil.
  • TerminalGuard takes commands from with_allowed_command and checks terminal/create. An empty command or an empty list denies, the command must match an entry exactly, and arguments carrying any of six shell metacharacters are rejected: a backtick, the sequence $(, |, ;, a newline, and a carriage return. The proxy launches through execve, so the check guards a command that later reaches a shell.
src/guards.rsrust
let config = AcpProxyConfig::new(agent_command, public_key)
    .with_agent_args(agent_args)
    .with_server_id("workspace")
    // FsGuard prefix set. One list covers both reads and writes, so scope
    // it to what the agent may WRITE, not only 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");

Reads and writes share one prefix set, so a wide prefix grants broad write access; keep it as narrow as the workflow allows. Keep the command list to the minimum the agent needs; the allowlist defines the command boundary. To govern terminal/kill and terminal/release, install a CapabilityChecker through start_with_kernel.

Server id and session capability ids

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 in one query. Keep it stable across restarts; changing it means updating scoped capabilities and receipt queries, and needs no key rotation.

A receipt's capability id is the id of the capability that authorized the operation. When none did, the signer synthesizes acp-session:<session_id> from the ACP session, the prefix chio cert generate matches on.

Audit entry fields

A session/update carrying a ToolCall event produces an audit entry, as does a ToolCallUpdate that carries a status; a status-less update produces nothing. Each entry arrives with a SHA-256 content hash and the session, tool-call, and server identifiers filled in.

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.
  • timestamp is a String holding seconds since the Unix epoch in decimal digits: "timestamp": "1744993921".
  • contentHash is the SHA-256 of the canonical JSON of the event's declared ACP fields: toolCallId, title, kind, and status for a ToolCall, and toolCallId plus status for an update. Extra keys the agent sends are excluded, so an unrecognized field cannot move the digest.
  • The eight authorization* fields are set together when a live capability check authorized the operation, so an entry that shows one shows all eight.
  • enforcementMode has two values, cryptographically_enforced and audit_only. 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 several pending contexts match, the binding is refused and the entry records audit_only.

What the signed receipt carries

With a signer installed, the entry becomes a ChioReceipt signed with the kernel's Ed25519 key. The enforcement mode alone decides two of its fields:

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(),
    ),
};

An audit_only entry signs as a trace observation with no decision and trust_level: verified; a cryptographically_enforced one signs as a mediated decision carrying allow and trust_level: mediated. The event signer constructs no deny decision: a request a built-in guard rejects is never forwarded, so it returns a JSON-RPC error and a log entry. A kernel-backed capability check signs its own admission refusal before this signer runs; inspect that authorization receipt and the proxy log beside the session-update receipts. The rest of the body:

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,
};
  • policy_hash and evidence are empty. A kernel-backed checker runs the configured kernel pipeline and records its result in the linked authorization receipt, which holds the policy and guard evidence.
  • action.parameters is the event. It holds tool_call_id, title, kind, status, and authorization_parameter_hash, with 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 the fallback. It is used only when the entry carries no capability id, the audit_only case. An enforced receipt carries the presented capability token's own id, and the two never appear together.
  • The metadata has two objects. metadata.acp is camelCase with 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 joins an ACP receipt to its authorization receipt without knowing which side wrote which.

Compliance certificates

A compliance certificate is the signed summary over one session. chio cert generate reads the receipts whose capability id starts with acp-session:<session_id> and emits a signed ComplianceCertificate. That prefix appears only on audit_only receipts, so a certificate summarizes what the proxy observed; enforced receipts carry their token's capability id and fall outside the prefix query.

Each selected receipt must repeat the session id at metadata.acp.sessionId, and the selected rows must be consecutive in the store. A database interleaving ACP receipts with MCP or A2A ones refuses on chain discontinuity, so give an ACP session its own receipt database.

Terminalbash
# 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 only 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

generate requires --session-id and --receipt-db; with --output and no --json it writes the file and prints nothing. Generation is fail-fast: it produces a certificate or an error, never a certificate recording a failure. A session with no receipts says so:

reputation-certs · cert-generate-emptytranscript
$ chio cert generate \
    --session-id demo-session \
    --receipt-db ./receipts.db \
    --authority-seed-file ./authority.seed
error[CHIO-CLI-SESSION-EMPTY]: no receipts found for session demo-session
  help: pass the --session-id of a session with receipts in the store
  code: urn:chio:error:cli:session-empty
  docs: https://chio.computer/docs/reference/errors#error-cli-session-empty
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 lines are per-dimension verdicts, each with a failing spelling: INVALID, BROKEN, VIOLATED, EXCEEDED, BYPASSED. A certificate this CLI generates prints the five compliant spellings and an empty anomaly list; the failing spellings appear on a certificate from elsewhere, or one that was edited. The body also reports the receipt count, the session's first and last receipt in store order, and the signer and kernel keys, and it is signed with the kernel's Ed25519 key, so a downstream consumer attests to a whole session without replaying the raw receipts.

chio cert verify requires --certificate and --trusted-kernel-pubkey, prints one line, and sets the exit code. Against a body whose signature fails:

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, which needs the receipt store, and refuses without it:

reputation-certs · cert-verify-full-no-dbtranscript
$ chio cert verify \
    --certificate ./cert.json \
    --trusted-kernel-pubkey ./kernel.pub \
    --full
error[CHIO-CLI-RECEIPT-STORE-REQUIRED]: full-bundle verification re-verifies every receipt signature and needs the receipt store
  help: pass --receipt-db <path>, or drop --full to check the certificate signature alone
  code: urn:chio:error:cli:receipt-store-required
  docs: https://chio.computer/docs/reference/errors#error-cli-receipt-store-required
exit 1

Differences from the MCP adapter

DimensionMCP adapterACP proxy
Shape
Request and response tool calls
Bidirectional JSON-RPC message stream
Interception point
tools/call request
Typed AcpMethod, plus session/update
Tool discovery
Proxied tools/list
Session-time advertisement, usually implicit
Checks
Compiled HushSpec guard pipeline
FsGuard, TerminalGuard, capability checks
Receipt trigger
Kernel-evaluated tool decisions
Observed tool-call events
Receipt capability id
The presented token's id
The token's id, or acp-session:<id>
Without a kernel
Refuses to start without its databases
Guards plus unsigned audit entries
Transport
stdio and Streamable HTTP
stdio, line-based JSON-RPC

The MCP guard pipeline covers forbidden-path, shell-command, egress-allowlist, path-allowlist, mcp-tool, secret-leak, and patch-integrity; the ACP proxy adds capability-checked terminal lifecycle and permission-kind mapping. On both, a refusal before the kernel lacks a kernel receipt: MCP edge-selection refusals and ACP built-in guard refusals live in errors and logs. Choose the evidence stream for the boundary you are reviewing.

Symptoms and fixes

  • access denied: fs read denied on every read. No prefix was supplied, or the project root is a symlink and the guard matched its canonical target. Add the canonical absolute path with with_allowed_path_prefix, resolving symlinks yourself first.
  • A path traversal error in the log. The agent requested a path with .. segments. The guard is working; a legitimate request normalizes the path before sending.
  • terminal/create denied for a command that should work. The command is off the TerminalGuard list, or the agent invoked it through a shell wrapper. Add it with with_allowed_command, or have the agent call the binary directly.
  • Entries read enforcementMode: audit_only. No CapabilityChecker is installed, or the session has no bound capability token. Start with start_with_kernel and a checker so the operation is capability-checked before it is forwarded.
  • Audit entries are unsigned. The proxy runs through AcpProxy::start with no ReceiptSigner. Use start_with_kernel with a signer, and AcpAttestationMode::Required to fail closed when signing fails.
  • access denied: terminal denied: suspicious argument on an argument that looks fine. The argument carries one of the six rejected characters; a carriage return from a Windows-authored file is the easy one to miss. Strip it; the guard has no escape syntax.
  • terminal/kill and terminal/release denied with -32000. No CapabilityChecker is installed. Install one through start_with_kernel; no allowlist opens these.
  • A built-in guard refusal has no session-update receipt. The request was blocked before forwarding, so the event signer had no tool-call event to sign. Retain the proxy error and log, and inspect any kernel admission receipt from the checker.
  • cert generate reports an empty session that produced receipts. Those receipts carry a capability id in place of the acp-session: fallback. Read them with chio receipt list --tool-server and join on metadata.acp.sessionId.
  • The upstream agent exits immediately. The -- boundary was misparsed, or the agent expects arguments the proxy did not forward. Find the spawning ACP agent log line and run the same command outside chio.

The mirror direction: chio-acp-edge

The sibling crate chio-acp-edge runs the other way: Chio exposes its own tools as an ACP server. The Hello ACP example's run-edge.sh and smoke.sh exercise session/list_capabilities, tool/invoke, deferred tool/stream, and tool/resume.

Next: read what the receipts record