ACP guard

Put DefenseClaw between an ACP editor client and coding agent to inspect prompts, streaming output, permissions, filesystem, terminal, and extension methods.

The ACP guard is a local, protocol-aware security boundary between an editor and an agent. It lets Zed, JetBrains, or another ACP client keep its native agent UX while every JSON-RPC frame passes through DefenseClaw policy.

starts, stdio
if set up separately
checked messages
asks, encrypted
evaluates
records
EditorZed or JetBrains
ACP guarddefenseclaw-acp
Direct agent entrynot guarded
ACP agentKiro, Cursor, …
Gateway on loopbackchecks each message
Policyrules, scanners, judge
Audit and telemetrylocal SQLite, OTLP

List view for small screens. Use the expand button to open the drawing.

  1. EditorZed or JetBrains
    • starts, stdioACP guard
    • if set up separatelyDirect agent entry
  2. Direct agent entrynot guarded
  3. ACP guarddefenseclaw-acp
    • checked messagesACP agent
    • asks, encryptedGateway on loopback
    • starts, stdioEditor
  4. Gateway on loopbackchecks each message
    • evaluatesPolicy
    • recordsAudit and telemetry
  5. ACP agentKiro, Cursor, …
    • checked messagesACP guard
  6. Audit and telemetrylocal SQLite, OTLP
  7. Policyrules, scanners, judge
The editor starts defenseclaw-acp instead of the agent. Every message between the editor and the agent passes through the guard, which asks the local gateway for a verdict before it forwards the message. An agent entry that the editor launches directly does not pass through the guard.

Use cases

  • Run Kiro in Zed or JetBrains without giving the editor an unmediated Kiro process.
  • Apply one guardrail profile to several native ACP agents instead of building a different extension for every IDE/agent pair.
  • Record prompt, output, permission, filesystem, terminal, tool, and vendor-extension traffic in the audit log and your telemetry.
  • Block a denied method or unsafe content before it crosses the editor-agent boundary.
  • Let an organization allow only selected client/agent pairs, such as zed + kiro, in a policy profile.

ACP complements connector hooks. Hooks observe vendor lifecycle events inside an agent. ACP protects the editor-to-agent protocol boundary, including client filesystem and terminal callbacks that a hook contract may not expose.

Quick start: Kiro in Zed

Install DefenseClaw, start the gateway, and add a guarded Kiro entry to Zed:

defenseclaw-gateway start
defenseclaw acp setup --client zed --agent kiro
defenseclaw acp status

Setup defaults to observe. Review the events, then explicitly activate blocking:

defenseclaw acp setup --client zed --agent kiro --activate

For JetBrains, replace --client zed with --client jetbrains. The command preserves unrelated JSON keys and foreign agent entries. It writes one DefenseClaw · Kiro entry, a mode-0600 scoped token, and a runtime contract lock. A failed config write rolls all three changes back.

Finding what is already unguarded

An editor may already be launching an ACP agent directly. That is exactly the traffic the guard exists to mediate, so DefenseClaw reads the clients it knows and says so rather than waiting to be asked about a specific pair:

defenseclaw acp detect
  zed — /Users/you/.config/zed/settings.json
  guarded    DefenseClaw · Kiro
  ⚠  UNGUARDED  cursor → launched from the client's own registry; replace with a guarded cursor entry
  foreign    Something Else (left untouched)
  installed but not configured here: devin, hermes, opencode

Each agent server falls into one class:

ClassMeaning
guardedThe entry launches defenseclaw-acp
adoptableA direct launch of a native catalog agent — the guard can wrap it
client_registryThe client resolves and launches the agent itself, so there is no argv to wrap. Unmediated, and adoption cannot close it
foreignNot an ACP agent DefenseClaw knows. Never rewritten

Then take the adoptable ones over:

defenseclaw setup acp          # or: defenseclaw acp adopt

Adoption runs the same acp setup transaction per pair — same executable resolution, digest pinning and contract lock — and then points the entry you already had at the guard, keeping its key and its env. Your familiar picker entry stops reaching the agent unmediated; it does not sit beside a guarded duplicate, because that would leave a bypass the guard cannot see. Re-running finds nothing to adopt rather than wrapping twice. Adoption defaults to observe; pass --activate for action mode.

First-run names the editors with unguarded agents and points here, so this is visible without knowing the command exists.

A client_registry entry cannot be adopted. DefenseClaw installs a guarded entry for the same agent when it has a native entry point, but the registry entry remains a way to launch that agent without the guard until you remove it in the client. detect keeps reporting it for exactly that reason.

Several guarded agents in one editor

You can guard more than one agent in the same editor. Run setup once per agent:

defenseclaw acp setup --client zed --agent kiro
defenseclaw acp setup --client zed --agent devin

Each run writes its own entry (DefenseClaw · Kiro, DefenseClaw · Devin), leaves the other entries and every foreign agent server untouched, and pins its own runtime contract lock at <data-dir>/acp/<client>-<agent>.contract-lock.json. The editor launches one defenseclaw-acp process per entry, so the two sessions are independent: separate stdio pipes, separate turn buffers, separate pending-ID accounting. A block in one does not interrupt the other.

Policy is per pair. Each <client>/<agent> binding resolves its own profile, so one editor can run one agent in action mode while another stays in observe. Give each pair its own profile with --profile at setup (without it, both pairs share the default profile, and setup refuses to change that profile's mode for one pair only):

defenseclaw acp setup --client zed --agent kiro --profile locked --activate
defenseclaw acp setup --client zed --agent devin --profile watch

Setup writes the binding, the profile, and the client and agent switches. The resulting configuration looks like this, and you can tighten a profile further by hand (here, denied_methods):

acp:
  enabled: true
  default_profile: locked
  clients:
    zed: {enabled: true}          # the pin records only that the half is enabled
  agents:
    kiro:  {enabled: true}
    devin: {enabled: true}
  bindings:
    zed/kiro:  {enabled: true, profile: locked}   # action
    zed/devin: {enabled: true, profile: watch}    # observe while it earns trust
  profiles:
    locked:
      mode: action
      fail_mode: closed
      allowed_clients: [zed]
      allowed_agents: [kiro]
      denied_methods: [fs/write_text_file]
    watch:
      mode: observe
      allowed_clients: [zed]
      allowed_agents: [devin]

defenseclaw acp status prints whether the guard is on, how many bindings it has, and each binding's health, resolved profile and mode. defenseclaw doctor lists the same bindings and fails on a broken one. defenseclaw acp status --json reports each binding's resolved profile and whether it came from the binding or was inherited:

"zed/kiro":  {"profile": "locked", "mode": "action",  "profile_source": "binding"}
"zed/devin": {"profile": "watch",  "mode": "observe", "profile_source": "binding"}

Resolution is most-specific-first: the bindings entry, then the agent pin, then the client pin, then default_profile. A configuration with no bindings therefore behaves exactly as it did before they existed.

Two boundaries a per-pair binding does not cross:

  • Disabling a client or an agent still disables every pair that uses it. A binding adds policy; it is not a way around either switch.
  • The resolved profile must still admit the pair through allowed_clients and allowed_agents.

A request naming a profile the configuration does not assign to that pair is refused rather than evaluated under either name. The guard's profile is pinned into its contract lock at setup, so a mismatch means a stale or forged request, and the refusal says so rather than reporting the profile as undefined.

That makes editing a pair's profile by hand a two-step change: the guard keeps the profile pinned in its argv until setup rewrites it. defenseclaw acp verify and defenseclaw acp status report the drift instead of calling the binding healthy, so you find out before the next editor session fails closed:

ACP binding verification failed: contract lock profile 'locked' no longer
matches the configured 'watch' for zed/kiro; re-run acp setup

Other consequences worth planning around:

  • allowed_agents is the "only these agents" control. Removing an agent from the shared profile refuses its evaluations while leaving its editor entry in place; removing the entry with defenseclaw acp remove --client zed --agent devin also reclaims its contract lock.
  • In normal user mode the agents share one scoped token, the private file named by argv. Managed enterprise enrollment instead mints one credential per principal + client + agent + profile, so two agents in one editor hold two distinct bearers and revoking one leaves the other working.
  • Each binding's digests are pinned separately. Upgrading the guard rebinds every matching user-owned lock in one transaction; a stale digest on one binding fails that binding closed without disturbing the other.

Managed enterprise enrollment

In a managed deployment, the administrator owns ACP policy and credentials, in the protected machine data directory. Each enrollment gets its own credential for one user, client, agent, and profile, and the gateway refuses a request whose client, agent, or profile differs from the enrollment.

An administrator or guardian first provisions the service record and the target user's private copy:

sudo /opt/defenseclaw/bin/defenseclaw-gateway enterprise acp enroll \
  --user alice --client zed --agent kiro --profile kiro-only --json

The JSON result contains a token_file and a next command, never the token value. Run the reported defenseclaw acp setup --managed ... command in the target user's session, or deploy the equivalent Zed/JetBrains JSON through MDM. Managed setup verifies the central allow-list, writes the editor launch entry and executable contract lock in user-owned state, and cannot modify central policy. The TUI exposes the same path under ACP Guard → Enroll managed enterprise ACP.

Use the same exact selectors to verify or revoke an enrollment:

sudo /opt/defenseclaw/bin/defenseclaw-gateway enterprise acp verify \
  --user alice --client zed --agent kiro --profile kiro-only
sudo /opt/defenseclaw/bin/defenseclaw-gateway enterprise acp revoke \
  --user alice --client zed --agent kiro --profile kiro-only

Revocation deletes the service-side credential first, so a copied or cached token stops working even if the user-side cleanup is interrupted. The user's private copy is written as that user. Enrolling again after a revocation issues a new credential.

The managed Windows payload, the macOS managed runtime and the Linux deb, rpm and payload archive include defenseclaw-acp as a separately hashed and protected executable (/opt/defenseclaw/bin/defenseclaw-acp on Linux).

UX and custodyNormal user modeManaged enterprise mode
Policy ownerUser configAdministrator-owned config; runtime mutation denied
SetupOne defenseclaw acp setup command or the ACP TUI panelAdmin enterprise acp enroll, then the reported user-side acp setup --managed command or MDM equivalent
CredentialOne private local ACP tokenUnique principal + client + agent + profile credential, with a private per-user copy
Enforcement rolloutObserve by default; --activate selects actionCentral profile chooses observe/action; the user cannot activate or downgrade it
RevocationRemove the guarded editor entryRemove service authority first, then the user copy; stale bearers fail immediately
ReadinessPrivate local token and configured bindingEvery enrollment record valid, and the binding configured

What is guarded

SurfaceACP methods or messagesProtection
Protocolinitialize, auth, session lifecycle, cancellation, extensionsStrict JSON-RPC 2.0 NDJSON validation; no batches; 1 MiB frame and 128 pending-ID bounds
Promptsession/promptRules, scanners, optional judge, observe/action policy
Outputsession/update and prompt responseEvaluated per frame and again as canonical per-field streams across the complete buffered turn; action mode releases nothing before the terminal prompt response passes
Approvalsession/request_permission, elicitation/createAllow or block; an unresolved confirmation verdict fails closed in action mode
Filesystemfs/read_text_file, fs/write_text_fileBidirectional content/method policy
Terminalterminal/*Bidirectional content/method policy
Extensionsfor example cursor/*Preserved as ACP extensions and evaluated as tool traffic

In action mode a blocked prompt ends that turn normally: the editor shows the reason as an agent message, in the wording the hook connectors use ("DefenseClaw policy blocked this action (rule SEC-AWS-KEY: AWS access key). Do not retry it in another form."), and the next prompt runs. Agent output blocked in the middle of a turn ends the turn the same way, and the guard cancels the turn in the agent. The guard can only withhold what the agent sends: an agent that runs its own tools has already run a step by the time its tool_call update arrives, so an output block says "DefenseClaw policy withheld agent output (rule ...). The agent may already have run the step; check its effects." To stop such a step before it runs, keep the agent's own connector hooks in action mode next to the guard. If the gateway does not answer an evaluation (the guard retries once), only that step is refused, with "DefenseClaw could not check this step because the gateway did not answer"; the session stays open.

The guard starts the agent directly, never through a shell. It talks only to the gateway on a loopback address, ignores proxy settings and redirects, and checks that it is talking to the real gateway before it sends anything. Each request is encrypted (AES-256-GCM) and each verdict is authenticated (HMAC), so another process that takes the gateway's port sees neither the credential nor the content and cannot fake a verdict; the guard fails closed instead. The ACP credential works for the ACP routes only. The contract lock pins the selected guard and agent executable paths and SHA-256 digests, the exact client configuration path and digest, plus ACP schema-v1.21.0. An upgrade replaces the guard, and the installer then re-pins the new guard in every user-owned lock that pinned the guard it replaced (defenseclaw acp refresh --from-sha256 <old digest>); a rolled-back upgrade restores the old locks with the rest of the data. A managed lock is not rewritten by that transaction; it instead permits an administrator upgrade to change guard bytes only while the complete guard path continues to pass the platform root/Admin ownership, no-symlink, and no-untrusted-writer custody checks. Agent and editor configuration digests stay exactly pinned in both modes. If central policy mode and the launched contract mode drift, the guard stops the session even when the old contract was observe-only; rerun setup to make an observe-to-action promotion explicit.

Observability

Every guarded decision is recorded as a guardrail.evaluation.completed event with these ACP attributes:

AttributeExample
defenseclaw.acp.clientzed
defenseclaw.acp.agentkiro
defenseclaw.acp.methodsession/prompt
defenseclaw.acp.directionclient_to_agent, agent_to_client
defenseclaw.acp.surfaceprompt, output, permission, filesystem, terminal, tool, protocol
defenseclaw.acp.profilekiro-only
defenseclaw.acp.protocol.versionschema-v1.21.0

The event keeps defenseclaw.guardrail.raw_action, defenseclaw.guardrail.effective_action, and defenseclaw.guardrail.would_block distinct, so an observe-mode pass-through cannot be mistaken for an action-mode allow. Matched rules are published in defenseclaw.guardrail.rule_ids with defenseclaw.guardrail.finding_count, alongside defenseclaw.guardrail.reason and defenseclaw.security.severity.

A blocked prompt therefore looks like this:

{
  "defenseclaw.acp.client": "zed",
  "defenseclaw.acp.agent": "kiro",
  "defenseclaw.acp.method": "session/prompt",
  "defenseclaw.acp.surface": "prompt",
  "defenseclaw.guardrail.decision": "block",
  "defenseclaw.guardrail.raw_action": "block",
  "defenseclaw.guardrail.effective_action": "block",
  "defenseclaw.guardrail.would_block": false,
  "defenseclaw.guardrail.rule_ids": ["TRUST-IGNORE-PREVIOUS"],
  "defenseclaw.guardrail.finding_count": 1,
  "defenseclaw.security.severity": "CRITICAL"
}

A profile-denied method blocks with finding_count 0 and no rule_ids: that veto comes from the allow-list, not a scanner match, and the event does not invent a finding to justify it.

A decision with findings (a block, an alert, or an observe-mode would-block) and a profile-denied method are also exported as a trace: an invoke_agent <agent> span (for example invoke_agent kiro, outcome blocked for a block) with an apply_guardrail child that carries the decision and rule IDs. Tempo shows both. Galileo has no guardrail span type, so it records the invoke_agent span, whose metadata names the user and the blocked outcome. The event, the finding rows and both spans carry the ACP sessionId as the session and conversation ID and the trace and span IDs of the decision. On a per-user gateway the finding rows and both spans also name the user the gateway runs as (user.id, defenseclaw.user.name). A rule-pack rule keeps its own ID in rule_ids.

Configuration

config_version: 8
acp:
  enabled: true
  mode: action
  default_profile: kiro-only
  clients:
    zed: {enabled: true, profile: kiro-only}
  agents:
    kiro: {enabled: true, profile: kiro-only}
  profiles:
    kiro-only:
      mode: action
      fail_mode: closed
      allowed_clients: [zed]
      allowed_agents: [kiro]
      denied_methods: [fs/write_text_file]

This profile rejects a guard invocation claiming another client or agent. It does not control an unrelated editor entry that launches an agent directly, outside defenseclaw-acp. To make “Kiro only” an organization-wide guarantee, combine the managed ACP binding with IDE policy/MDM and operating-system application control that prevents unapproved agent binaries or configuration. DefenseClaw deliberately reports that bypass boundary instead of claiming it can govern a process it never receives.

In managed_enterprise, allowed_clients and allowed_agents must explicitly name the enrolled pair; an empty wildcard allow-list is deliberately rejected by enrollment even though it remains valid for ordinary local profiles.

Supported agents

DefenseClaw ships its own list of ACP clients and agents, kept separately from connector hooks. For each agent it records whether the entry point is native or a bridge, where it is documented, and its ID in the upstream ACP Registry when it has one. defenseclaw acp catalog prints the list.

Kiro's native hooks are a separate, second enforcement path; see Kiro. The ACP guard needs none of that and covers every surface in the table above on its own. ACP adds no secret environment variable: agents keep their own credentials, and the guard token is a private file named on the guard's command line.

DefenseClaw connectorACP formEntry point
KiroNativekiro-cli acp
CursorNativeagent acp
OpenCodeNativeopencode acp
HermesNativehermes acp
GitHub Copilot CLINative, public previewcopilot --acp --stdio
DevinNativedevin acp

The guard supports native ACP entry points only — an agent the vendor ships with an ACP mode of its own. Community bridges, vendor companion servers, and gateway-backed shims are deliberately out of scope: each adds a third-party process between DefenseClaw and the agent whose argv, schema and release cadence DefenseClaw does not control, which is the wrong place for a security boundary to depend on. Every other DefenseClaw connector therefore reports no ACP claim and is guarded through its native hook surface instead.

A listed entry point was checked against the vendor's documentation or the ACP Registry; that alone does not mean DefenseClaw tested that editor and agent together.

Setup uses only the list that ships with DefenseClaw. It never downloads or runs commands from the online registry. Every selected guard and agent executable is resolved to an absolute path, hashed, and bound into the client-specific contract lock before the editor can launch it.

API and diagnostics

EndpointPurpose
POST /api/v1/acp/challengeEmpty, scoped request that authenticates the loopback gateway before ACP content is encrypted and sent
POST /api/v1/acp/evaluateScoped, loopback-only, mutually authenticated frame evaluation
GET /v1/acp/catalogCompiled client/agent catalog
GET /v1/acp/profilesEffective profile inventory
GET /healthACP enabled/mode/schema/configuration/token readiness

In managed mode, scoped_token_ready is true only when every enrollment record is private and valid; one bad record makes it false.

Use defenseclaw acp catalog for the local catalog and defenseclaw acp remove --client zed --agent kiro for surgical removal.