@agenticcontrolplane/fx
v0.1.0
Published
Agentic Control Plane for fx (vercel-labs/fx) — a governed protocol middleware for fx's Agent Client Protocol mode, checking every tool call against your policies before fx's own permission prompt reaches the client.
Maintainers
Readme
@agenticcontrolplane/fx
Agentic Control Plane for fx, Vercel Labs' Zig coding agent: every tool call fx acp wants to run is checked against your policies before fx's own permission prompt ever reaches you, and every decision is recorded.
acp-fx is not a plugin loaded inside fx — fx has no plugin API. It's a protocol middleware: a small stdio proxy that sits between a real Agent Client Protocol client (Zed, or any ACP driver) and the fx acp subprocess, forwarding every JSON-RPC message transparently in both directions except one: session/request_permission.
Zed (or any ACP client) ──stdio──▶ acp-fx ──stdio──▶ fx acp
◀──────────────────────────────
│
session/request_permission
intercepted here, checked against
POST /govern/tool-use, resolved or
forwarded to the client per the
table belowWhy a proxy, not a plugin
fx's ACP server (src/acp/server.zig) already emits a session/request_permission JSON-RPC request for every approval-gated tool call, with the tool's rawInput attached. That is a complete interception point — acp-fx answers it directly (allow/deny) or relays it to the real client (ask), without fx needing to know acp-fx exists. Point your ACP client at acp-fx -- fx acp instead of fx acp and nothing else about your fx setup changes.
Install
Not yet published to npm (the name @agenticcontrolplane/fx is reserved in package.json). For now, install from git:
git clone https://github.com/agentic-control-plane/fx-acp-plugin.git ~/dev/fx-acp-plugin
cd ~/dev/fx-acp-plugin && npm link # puts `acp-fx` on your PATHGet a key at cloud.agenticcontrolplane.com and save it to ~/.acp/credentials, or set ACP_API_KEY / ACP_BEARER_TOKEN. Requires Node 22+.
No key? acp-fx says so loudly on stderr at startup and stays out of the way — see Failure posture; it never bricks the session, it just stops being able to consult policy.
Usage
Command line
acp-fx [--unattended] [--timeout-ms N] [--no-output-governance] -- <command> [args...]
acp-fx -- fx acp
acp-fx --unattended -- fx acp # for a scripted/CI driver with no human on the other endEverything after -- is the command acp-fx spawns and proxies to. Point it at whatever runs fx acp — a bare fx acp, or an absolute path if fx isn't on the spawned process's PATH.
Zed
Zed's external agent config goes under agent_servers in settings.json (type, command, args, env, cwd — confirmed against Zed's docs and community examples; agent: open settings opens this block for you):
{
"agent_servers": {
"fx (governed)": {
"type": "custom",
"command": "acp-fx",
"args": ["--", "/absolute/path/to/fx", "acp"],
"env": {}
}
}
}Use an absolute path to acp-fx too if npm link didn't put it on the shell Zed launches with. Run dev: open acp logs from Zed's command palette to see the raw traffic if something looks wrong — that's genuine ACP traffic between Zed and acp-fx, not fx-internal logging.
How it works
| fx message | What acp-fx does |
|---|---|
| session/request_permission (request, fx → client) | Intercepted. POST /govern/tool-use, then resolved per the table below. |
| session/update with tool_call_update (notification, fx → client) | Output governance (see below) — forwarded, possibly with its displayed text replaced. |
| Everything else, both directions | Passed through byte-for-byte, unmodified. |
Decision table
| Governance decision | Attended (a client is connected) | Unattended (--unattended) |
|---|---|---|
| allow | Reply allow_once directly to fx. The client never sees the prompt. | Same. |
| deny | Reply reject_once directly to fx. | Same. |
| ask | Forward the original session/request_permission to the real client — a human decides, same as running fx directly. | Reply reject_once — an unattended agent cannot self-approve ("empty chair"). |
| gateway unreachable / timeout (~3s, one transport retry) | Forward to the client, fail-open, with a loud ⚠ UNGOVERNED line on stderr. | Reply reject_once, fail-closed. |
| no credential found | Treated identically to "gateway unreachable" — an uncredentialed control plane must never be mistaken for a live one. | Same. |
A response is decided in allow_once / reject_once only — acp-fx never returns allow_always, even when the policy says allow, so every subsequent call for the same tool still goes through governance instead of fx caching a blanket approval.
One pending permission request at a time: fx's own beginOutboundRequest/awaitPermissionDecision (src/acp/server.zig) never has more than one session/request_permission in flight, so acp-fx does no correlation bookkeeping — the forwarded request's id round-trips straight back through the client→fx passthrough. Other fx traffic (progress notifications, etc.) is never blocked behind a pending decision; see the "interleaved" test in test/bridge.test.mjs.
Every decision gets one audit line on stderr: tool, kind, decision, source, latency, call id, session id, and reason when present.
Output governance
fx's session/update tool_call_update notifications carry the tool's result text to the client — this is a genuine, empirically-confirmed exposure point (see "Protocol uncertainties" below), so acp-fx also posts it to POST /govern/tool-output and can block or redact what's displayed. Disable with --no-output-governance.
This is display-layer only. ACP tool results do not round-trip back into fx's own model context — fx already sent the raw result to its model before this notification goes out to the client. Blocking or redacting here changes what the human sees in Zed; it cannot un-show the output to the model that already acted on it. Treat it as DLP-for-the-transcript, not as a second enforcement point on what the agent knows.
An outage during output governance is a silent pass-through (the original notification goes out unchanged) — it's observability on a display notification, not a policy gate; the loud fail-open/fail-closed posture above is reserved for the pre-execution check, where it actually controls whether something runs.
Failure posture
Same posture as the rest of the ACP family (gatewaystack-connect#690): one transport-failure retry (never a retry on an HTTP status — a 429 is the server answering, re-rolling it deepens the rate limit), then:
- Attended sessions fail open, loudly. The request goes to the real client so a human can still decide; stderr gets a
⚠ UNGOVERNEDline and (best-effort)~/.acp/lapse.loggets a row. - Unattended sessions fail closed.
reject_once, no exceptions — nobody is there to be the fallback.
Configuration
Environment variables (all optional):
| Variable | Default | Purpose |
|---|---|---|
| ACP_API_KEY / ACP_BEARER_TOKEN | ~/.acp/credentials or ~/.acp/proxy-key | Workspace key. |
| ACP_GOVERN_BASE / ACP_API_BASE | https://govern.agenticcontrolplane.com | Gateway, or your self-hosted one. |
| ACP_FX_UNATTENDED | unset | Same as passing --unattended. |
FX_PERMISSION_MODE is a special case, not a pass-through: acp-fx always strips it from the spawned fx process's environment, regardless of what value your shell has it set to. FX_PERMISSION_MODE=yolo bypasses fx's entire permission system (src/core/app/app_lifecycle.zig loadPermissionMode) — with yolo, fx never emits session/request_permission at all, which would make every decision this shim makes moot. See lib/spawn-env.js. fx's own default (confirmed ask, both by reading the source and by observing a live session/new response with no env var set) is left alone rather than forced, so a future fx release changing its default doesn't require a change here too — as long as it never changes it to yolo.
Protocol uncertainties
Verified empirically against the fx v0.0.3 binary and by reading the vendored Zig source (src/acp/prompt.zig, src/acp/server.zig, src/acp/types.zig, src/core/app/app_lifecycle.zig, src/core/shared/types.zig) rather than assumed from the ACP spec alone:
- The original tool name never reaches the wire.
session/request_permission'stoolCallcarries a human-readabletitle(e.g."Run: rm -rf /tmp/scratch") and a coarsekind(read/edit/delete/move/execute/search/other), not fx's internal tool identifier. acp-fx sendstitleastool_namein the governance payload and addstool_kindalongside it so policy can match on the coarse category too — write rules againsttitlesubstrings andtool_kind, not against an internal tool-name enum, because fx doesn't expose one. - A real
session/request_permissionrequires a real model call, which in turn requires a realAI_GATEWAY_API_KEY. This was not available in this environment, so the deny/allow path is verified two ways: (a) theinitializehandshake is verified end-to-end against the real fx v0.0.3 binary with a dummy key (this alone doesn't require a model call and passes); (b) the full request→decide→reply round trip is verified against a hand-rolled fixture (test/fixtures/fake-fx.mjs) that speaks the same wire shape fx does, confirmed line-for-line againstsrc/acp/server.zig's own test cases. If fx's wire shape forsession/request_permissionchanges in a future release, that fixture (and thewithReplacedText/isToolCallUpdatehelpers inlib/bridge.js) is the place to check first. - Unattended detection is flag-based, not inferred, because a stdio bridge always has something on the other end of
clientIn/clientOut— there's no reliable signal like pi'sctx.hasUIto read. acp-fx defaults to attended (the primary use case is a real client like Zed) and requires an explicit--unattendedflag orACP_FX_UNATTENDED=1for scripted/CI use. Running an unattended driver without that flag risks a real hang: anaskdecision would forwardsession/request_permissionto a "client" that never answers it, and fx's one-pending-request-at-a-time constraint means the whole session stalls behind it. If you're scripting fx through acp-fx with no human loop, always pass--unattended. - No synthetic
session/updateon deny. acp-fx could inject an extra notification explaining why a call was denied, but that risks stepping on fx's own ownership of that notification stream in ways not fully characterized from the outside. Per the task's own fallback, denial reasons are logged to stderr only; the client sees the tool call resolve asreject_once, the same shape fx already produces when a human declines its own prompt. - fx's own reviewer-model caveat stays fx's. fx has a documented, separate feature where a reviewer model can second-guess tool calls; that's orthogonal to this middleware and out of scope here — acp-fx only ever sees what fx already decided needs a permission check.
Test
npm test # 40 tests: decision mapping, fail posture, env stripping, NDJSON framing, e2etest/ndjson.test.mjs,test/govern.test.mjs,test/spawn-env.test.mjs,test/bridge.test.mjs— pure unit tests, no network, no subprocess (govern tests use a localnode:httpstub, never the production gateway).test/e2e.test.mjs— spawns real child processes. One test drives the real fx v0.0.3 binary from~/dev/fx-acp-spike/bin/fxthrough theinitializehandshake (skipped automatically if that binary isn't present on the machine running the tests); two more drivetest/fixtures/fake-fx.mjs, a minimal hand-rolled ACP speaker, through the full deny/allowsession/request_permissionround trip against a local governance stub.scripts/smoke.sh— not run bynpm test. A human-runnable script against the real production gateway; see the script's own header for what it checks and what it needs (ACP_API_KEYpointed at a real workspace).
MIT
