feat(acp): bundle into CLI, on-demand install, S1 safety + evidence (U8)

Wires the ACP runtime plugin into the published CLI (RUNTIME_PLUGIN_IDS in
tsup.config) and the on-demand BUILTIN_PLUGINS catalog (experimental), matching
the untrusted-subprocess security posture. Adds the Risk S1 default-policy
safety: an acpAllowUnrestricted acknowledgement (default false) — without it, a
blanket allow on a sensitive category is escalated to approval rather than
auto-approved under the allow-all default policy, applied in both the permission
floor and fs write gating. Adds docs/acp-contract.md (launch/readiness +
failure taxonomy), a README with the AGENTS.md-required upstream evidence
(SDK repo/docs/release/integrity), a bundle-output test for the staged plugin,
and a @runfusion/fusion minor changeset. Package green at 179 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 09:25:56 -07:00
parent d9f1392b86
commit 489a287d6f
16 changed files with 309 additions and 11 deletions

View File

@@ -0,0 +1,17 @@
---
"@runfusion/fusion": minor
---
Add an ACP (Agent Client Protocol) client runtime plugin (`runtimeId: "acp"`)
that drives any external ACP-compatible agent over JSON-RPC/stdio, built on the
official `@agentclientprotocol/sdk`. Installed on demand (experimental).
The agent runs as an untrusted subprocess that calls back into Fusion, so the
integration ships a defense-in-depth security floor: per-category permission
gating against the live policy (never a preset shortcut; `allow_once` only;
unmappable kinds and missing policy default-deny), an unrestricted-risk
acknowledgement that escalates blanket allows to approval under the allow-all
default, an opt-in filesystem capability behind a real symlink-resolving cwd jail
(realpath + `O_NOFOLLOW`, secret/`.git` deny-list, writes gated through the
permission policy), untrusted-output sanitization and bounds, and an env
allow-list for the subprocess.

66
docs/acp-contract.md Normal file
View File

@@ -0,0 +1,66 @@
# ACP (Agent Client Protocol) Runtime Contract
Date: 2026-06-03
Launch/readiness contract and failure taxonomy for `fusion-plugin-acp-runtime`,
which drives any external [Agent Client Protocol](https://agentclientprotocol.com)
agent over JSON-RPC/stdio. Mirrors the shape of `docs/cursor-cli-contract.md`.
## Transport
- **Newline-delimited JSON-RPC 2.0 over stdio** (no Content-Length framing).
Provided by `@agentclientprotocol/sdk` (`ndJsonStream` + `ClientSideConnection`).
- The client (Fusion) launches the agent as a subprocess with piped stdio. The
agent's stdin is the JSON-RPC *output* stream; its stdout is the *input* stream.
- `stderr` is captured (redacted) for diagnostics, never parsed as protocol.
## Invocation and binary detection
- Unlike a single-vendor CLI, ACP is a protocol — the agent binary + ACP-mode
flag are user-configured:
- `acpBinaryPath` — e.g. `gemini`, `npx`, or an absolute path.
- `acpArgs` — the flag(s) that put the agent in ACP/stdio mode, e.g. `["--acp"]`.
- The subprocess environment is built from the `acpEnvAllowList` allow-list only
(inherited `process.env` is **not** forwarded — the agent is untrusted).
## Readiness = the `initialize` handshake
There is no `--version` probe. Readiness is the protocol handshake itself:
1. Spawn the agent subprocess.
2. Send `initialize { protocolVersion: 1, clientCapabilities: { fs } }` under a
timeout (default 30s — research flagged Gemini-on-macOS OAuth and Claude-adapter
`session/new` stalls).
3. The agent responds with its integer `protocolVersion`, `agentCapabilities`,
and `authMethods`.
4. The client compares the integer protocol version; an unsupported version is a
hard failure (do not assume the agent errors first).
`fs` capabilities are advertised **only** when `acpFsRead`/`acpFsWrite` are
enabled (writes default OFF).
## Failure taxonomy (`probe.ts` `AcpProbeReason`)
| Reason | Trigger |
| --- | --- |
| `ok` | Handshake completed (with `authRequired: true` when `authMethods` is non-empty) |
| `missing_binary` | Spawn `ENOENT` (binary not found, code 127) |
| `spawn_error` | Other spawn failure |
| `handshake_timeout` | `initialize` did not complete within the bound (code 124) |
| `incompatible_protocol` | Agent negotiated an unsupported integer protocol version |
| `unauthenticated` | Agent requires an auth method the client cannot satisfy |
## Lifecycle / teardown
- The engine has no `AbortSignal` in the runtime contract; teardown enters via an
unawaited synchronous `dispose()` plus the process-registry kill. The
**registry SIGKILL is the authoritative no-orphan / no-deadlock guarantee**; a
best-effort `session/cancel` + pending-permission drain runs first when timing
allows but is opportunistic.
## Sources
- https://agentclientprotocol.com (introduction, schema, transports, initialization, tool-calls)
- `@agentclientprotocol/sdk` v0.24.0 — https://www.npmjs.com/package/@agentclientprotocol/sdk
- Validation: the SDK example echo agent (CI) + an in-repo controllable fixture
(`src/__tests__/fixtures/echo-agent.mjs`); Gemini CLI / Claude-adapter for manual e2e.

View File

@@ -260,6 +260,23 @@ describe("CLI bundle output", () => {
expect(manifest.name?.length).toBeGreaterThan(0);
});
it("dist/plugins/fusion-plugin-acp-runtime/ is staged with the acp runtime manifest", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-acp-runtime");
const manifestPath = join(stagedRoot, "manifest.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as {
id?: string;
runtime?: { runtimeId?: string };
};
expect(manifest.id).toBe("fusion-plugin-acp-runtime");
// The runtime is selected by runtimeId; assert it is "acp".
expect(manifest.runtime?.runtimeId).toBe("acp");
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
// v1 ships no mcp-schema-server.cjs (MCP forwarding deferred, KTD5).
expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(false);
});
it("pi-claude-cli source imports child process helpers from node:child_process", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");

View File

@@ -57,6 +57,14 @@ export const BUILTIN_PLUGINS: BuiltinPluginCatalogEntry[] = [
path: "./plugins/fusion-plugin-droid-runtime",
experimental: true,
},
{
id: "fusion-plugin-acp-runtime",
name: "ACP Runtime",
description: "Runtime provider that drives any external Agent Client Protocol agent over JSON-RPC/stdio.",
category: "runtime",
path: "./plugins/fusion-plugin-acp-runtime",
experimental: true,
},
{
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",

View File

@@ -14,6 +14,7 @@ const RUNTIME_PLUGIN_IDS = [
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-droid-runtime",
"fusion-plugin-acp-runtime",
] as const;
const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([

View File

@@ -0,0 +1,66 @@
# @fusion-plugin-examples/acp-runtime
A Fusion runtime plugin that drives **any** external [Agent Client Protocol
(ACP)](https://agentclientprotocol.com) agent over JSON-RPC/stdio. One
integration unlocks every ACP-compatible agent (Gemini CLI, the Claude Code ACP
adapter, and any future agent that speaks the protocol) through the standard
protocol instead of a bespoke per-CLI integration.
Selected via `runtimeId: "acp"`. Installed on demand (`experimental`) — see the
Fusion plugin catalog (`fn plugin install fusion-plugin-acp-runtime`).
## Security posture
The ACP agent is an **untrusted subprocess** that calls back into Fusion for
permissions and filesystem access. This plugin enforces a defense-in-depth floor:
- **Per-category permission gating.** Each `session/request_permission` is
classified by tool kind into a Fusion action category and checked against the
live permission policy — never a preset shortcut. `allow_once` only (never a
persisted blanket grant). Unmappable kinds and missing policy default-deny.
- **Unrestricted-risk acknowledgement (`acpAllowUnrestricted`).** Because the
shipped default policy is `unrestricted` (allow-all), a blanket `allow` on a
*sensitive* category is escalated to approval unless the user explicitly sets
`acpAllowUnrestricted: true`. Prefer running the ACP runtime under an
`approval-required` policy.
- **Filesystem jail.** `fs/read_text_file` / `fs/write_text_file` are opt-in
(`acpFsRead` / `acpFsWrite`, writes default OFF), confined to the session
`cwd` by a real symlink-resolving jail (realpath + `O_NOFOLLOW`), with a
deny-list for secrets (`.env`, `*.pem`, …) and git internals (`.git/**`).
Writes are gated through the `file_write_delete` permission category.
- **Untrusted-input bounds.** Streamed output is sanitized (ANSI/control strip)
and bounded (per-turn + per-chunk caps; bounded tool-call correlation map).
- **Subprocess isolation.** The agent env is built from an allow-list
(`acpEnvAllowList`) — inherited `process.env` is **not** forwarded.
Not sandboxed in v1: the agent's own process/network syscalls run with Fusion's
user privileges (OS-level sandboxing is recommended future work).
## Settings
| Key | Default | Meaning |
| --- | --- | --- |
| `acpBinaryPath` | `acp-agent` | Agent binary to spawn |
| `acpArgs` | `[]` | Args that launch the agent in ACP/stdio mode (e.g. `["--acp"]`) |
| `acpModel` | — | Optional model identifier reported via `describeModel` |
| `acpFsRead` | `false` | Advertise/register `fs/read_text_file` |
| `acpFsWrite` | `false` | Advertise/register `fs/write_text_file` (gated) |
| `acpEnvAllowList` | `[]` | Env var names forwarded to the agent subprocess |
| `acpAllowUnrestricted` | `false` | Acknowledge the untrusted-agent risk under an allow-all policy |
## Upstream / third-party integration evidence
Per `AGENTS.md` (External-integration evidence):
- **Protocol homepage / docs:** https://agentclientprotocol.com
- **Upstream protocol repo:** https://github.com/agentclientprotocol/agent-client-protocol
- **TypeScript SDK repo:** https://github.com/agentclientprotocol/typescript-sdk
- **Dependency (npm):** `@agentclientprotocol/sdk` — https://www.npmjs.com/package/@agentclientprotocol/sdk
- **Pinned release:** `0.24.0` (Apache-2.0)
- **Tarball:** https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.24.0.tgz
- **Integrity (sha512):** `sha512-vvu9appvGvfYstBj19C6NCepV6SvUhY5VRv60KUZ4XzhTah/olOYul5Zo4C+x2enyshMSvgB2mm/OEmrsHaSmA==`
- **Agent binaries driven:** user-supplied ACP agents (e.g. `gemini --acp`, the
`@agentclientprotocol/claude-agent-acp` adapter). These are configured by the
user at runtime, not bundled — `upstream-pending-verification` per agent.
See `docs/acp-contract.md` for the launch/readiness contract and failure taxonomy.

View File

@@ -110,13 +110,37 @@ describe("resolvePermission — the security floor", () => {
});
// [Risk S2] allow → allow_once, allow_always NEVER selected.
// (acknowledged: with allowUnrestricted the S1 escalation is off, so the allow
// disposition reaches option selection — the point of this test.)
it("selects allow_once for an allow category and never allow_always even when offered", async () => {
const gate = gateWithRules({ ...UNRESTRICTED, command_execution: "allow" });
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate, {
allowUnrestricted: true,
});
expect(selectedId(res)).toBe("allow_once_id");
expect(selectedId(res)).not.toBe("allow_always_id");
});
// [Risk S1] WITHOUT acknowledgement, a blanket allow on a sensitive category
// is escalated to approval — and default-denies when no approver exists.
it("escalates a sensitive allow to deny under the unrestricted default (no acknowledgement, no approver)", async () => {
const gate = gateWithRules(UNRESTRICTED); // command_execution: "allow"
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate);
expect(selectedId(res)).toBe("reject_once_id");
});
it("auto-allows a sensitive call only when the unrestricted risk is acknowledged", async () => {
const gate = gateWithRules(UNRESTRICTED);
const res = await resolvePermission(toolCall("execute"), ALL_OPTIONS, gate, {
allowUnrestricted: true,
});
expect(selectedId(res)).toBe("allow_once_id");
});
it("never escalates an exempt (read-only) kind regardless of acknowledgement", async () => {
const gate = gateWithRules(UNRESTRICTED);
const res = await resolvePermission(toolCall("read"), ALL_OPTIONS, gate);
expect(selectedId(res)).toBe("allow_once_id");
});
it("exempt kinds (read) always allow via allow_once", async () => {
// Even with a block-everything policy, a read-only kind is exempt → allow.
const gate = gateWithRules({
@@ -159,7 +183,9 @@ describe("resolvePermission — the security floor", () => {
{ optionId: "allow_always_id", name: "Allow always", kind: "allow_always" },
{ optionId: "reject_always_id", name: "Reject always", kind: "reject_always" },
];
const res = await resolvePermission(toolCall("execute"), noAllowOnce, gate);
const res = await resolvePermission(toolCall("execute"), noAllowOnce, gate, {
allowUnrestricted: true,
});
// No reject_once either → cancelled, and definitely not allow_always.
expect(res.outcome.outcome).toBe("cancelled");
expect(selectedId(res)).toBeUndefined();

View File

@@ -134,7 +134,9 @@ describe("writeTextFile", () => {
}
it("writes within cwd when policy allows; content persists and reads back", async () => {
const res = await writer(allowGate)({
// Acknowledge the unrestricted risk so an `allow` disposition isn't escalated
// to approval (S1) — this test exercises the allow→write path itself.
const res = await writer(allowGate, { allowUnrestricted: true })({
sessionId: "s",
path: "out.txt",
content: "written-by-agent",
@@ -144,6 +146,14 @@ describe("writeTextFile", () => {
expect(onDisk).toBe("written-by-agent");
});
it("escalates an allow write to approval/deny without the unrestricted acknowledgement (S1)", async () => {
// allowGate sets file_write_delete: "allow", but with no acknowledgement and
// no approver the write must be denied, not silently written.
await expect(
writer(allowGate)({ sessionId: "s", path: "out2.txt", content: "x" } as never),
).rejects.toThrow();
});
it("rejects an oversized write before touching the fs", async () => {
await expect(
writer(allowGate, { writeMaxBytes: 10 })({

View File

@@ -59,6 +59,13 @@ describe("resolveCliSettings", () => {
expect(s.fsWrite).toBe(false);
// env allow-list empty by default (KTD6b) — no inherited process.env.
expect(s.envAllowList).toEqual([]);
// Risk S1 acknowledgement is off by default (safe).
expect(s.allowUnrestricted).toBe(false);
});
it("honors the acpAllowUnrestricted acknowledgement", () => {
expect(resolveCliSettings({ acpAllowUnrestricted: true }).allowUnrestricted).toBe(true);
expect(resolveCliSettings({ acpAllowUnrestricted: "yes" }).allowUnrestricted).toBe(false);
});
it("honors explicit binary, args, and capability toggles", () => {

View File

@@ -46,12 +46,26 @@ function selectedId(res: RequestPermissionResponse): string | undefined {
}
describe("createBridgingClientHandler — requestPermission delegates to the gate", () => {
it("answers allow_once for an allow category", async () => {
const { handler } = createBridgingClientHandler({}, gate({ ...UNRESTRICTED, command_execution: "allow" }));
it("answers allow_once for an allow category (risk acknowledged)", async () => {
const { handler } = createBridgingClientHandler(
{},
gate({ ...UNRESTRICTED, command_execution: "allow" }),
undefined,
{ allowUnrestricted: true },
);
const res = await handler.requestPermission(req("execute"));
expect(selectedId(res)).toBe("allow_once_id");
});
it("escalates a sensitive allow to deny without the unrestricted acknowledgement (S1)", async () => {
const { handler } = createBridgingClientHandler(
{},
gate({ ...UNRESTRICTED, command_execution: "allow" }),
);
const res = await handler.requestPermission(req("execute"));
expect(selectedId(res)).toBe("reject_once_id");
});
it("default-denies (reject_once) when no gate is supplied", async () => {
const { handler } = createBridgingClientHandler({});
const res = await handler.requestPermission(req("read"));

View File

@@ -23,6 +23,16 @@ export interface AcpCliSettings {
* default — callers opt specific vars in by name.
*/
envAllowList: string[];
/**
* Risk S1 acknowledgement. The shipped default permission policy is
* `unrestricted` (every category → allow). Because the ACP agent is an
* untrusted subprocess, the permission floor refuses to auto-approve a
* *sensitive* category on a blanket `allow` disposition unless the user has
* explicitly acknowledged that risk by setting this true — otherwise such
* calls are escalated to approval (or denied when no approver exists).
* Default: false (safe).
*/
allowUnrestricted: boolean;
}
function asTrimmedString(value: unknown): string | undefined {
@@ -46,5 +56,6 @@ export function resolveCliSettings(settings?: Record<string, unknown>): AcpCliSe
const fsRead = asBool(settings?.acpFsRead);
const fsWrite = asBool(settings?.acpFsWrite);
const envAllowList = asStringArray(settings?.acpEnvAllowList) ?? [];
return { binaryPath, args, model, fsRead, fsWrite, envAllowList };
const allowUnrestricted = asBool(settings?.acpAllowUnrestricted);
return { binaryPath, args, model, fsRead, fsWrite, envAllowList, allowUnrestricted };
}

View File

@@ -232,10 +232,40 @@ export async function runApprovalForCategory(
* `require-approval` without a resolvable approver, or a missing `allow_once`
* option.
*/
export interface ResolvePermissionOptions {
/**
* Risk S1 acknowledgement. When false (the safe default), a blanket `allow`
* disposition on a *sensitive* category is escalated to `require-approval`
* rather than auto-approved — so the shipped `unrestricted` default policy
* does not silently green-light an untrusted agent's command/file/network
* calls. The user opts out of the escalation by acknowledging the risk.
*/
allowUnrestricted?: boolean;
}
/**
* Per-category disposition with the Risk S1 acknowledgement escalation applied:
* a *sensitive* category the policy would `allow` is upgraded to
* `require-approval` unless `allowUnrestricted` is set. `exempt` (read-only)
* never escalates. Exported so the fs write path applies the identical rule.
*/
export function effectiveDisposition(
category: FusionCategory | "exempt",
gate: PermissionGate,
opts?: ResolvePermissionOptions,
): GateDisposition {
const disposition = dispositionFor(category, gate);
if (disposition === "allow" && category !== "exempt" && opts?.allowUnrestricted !== true) {
return "require-approval";
}
return disposition;
}
export async function resolvePermission(
toolCall: ToolCallUpdate,
options: PermissionOption[],
gate: PermissionGate | undefined,
opts?: ResolvePermissionOptions,
): Promise<RequestPermissionResponse> {
// No gate / no policy → default-deny.
if (!gate || !gate.permissionPolicy) {
@@ -248,7 +278,8 @@ export async function resolvePermission(
return buildResponse(selectOption("deny", options));
}
const disposition = dispositionFor(category, gate);
// Per-category disposition + S1 acknowledgement escalation.
const disposition = effectiveDisposition(category, gate, opts);
if (disposition === "allow") {
return buildResponse(selectOption("allow", options));

View File

@@ -25,7 +25,7 @@ import {
openWithinCwd,
PathJailError,
} from "./path-jail.js";
import { dispositionFor, runApprovalForCategory } from "./control-handler.js";
import { effectiveDisposition, runApprovalForCategory } from "./control-handler.js";
import type { PermissionGate } from "./types.js";
/** Hard ceiling on bytes returned from a read when `limit` is absent/huge (S5). */
@@ -61,6 +61,12 @@ export interface FsHandlerOptions {
allowRead: boolean;
/** Advertise/register `writeTextFile` (default OFF — KTD6). */
allowWrite: boolean;
/**
* Risk S1 acknowledgement. When false (default), a blanket `allow` on the
* `file_write_delete` category is escalated to `require-approval` for the
* untrusted agent rather than auto-approved.
*/
allowUnrestricted?: boolean;
/** Override the read byte ceiling (tests). */
readMaxBytes?: number;
/** Override the write byte ceiling (tests). */
@@ -181,7 +187,9 @@ export function createFsHandlers(opts: FsHandlerOptions): FsHandlers {
// security floor stays single-sourced.
const gate = opts.gate;
const disposition = gate?.permissionPolicy
? dispositionFor("file_write_delete", gate)
? effectiveDisposition("file_write_delete", gate, {
allowUnrestricted: opts.allowUnrestricted,
})
: "require-approval";
if (disposition === "block") {

View File

@@ -32,6 +32,15 @@ const plugin: FusionPlugin = definePlugin({
`ACP Runtime Plugin loaded — binary=${settings.binaryPath} args=[${settings.args.join(" ")}] ` +
`fsRead=${settings.fsRead} fsWrite=${settings.fsWrite}`,
);
// Risk S1: the ACP agent is an untrusted subprocess. Acknowledging the
// unrestricted policy disables the per-call approval escalation — warn so
// it is a deliberate, visible choice.
if (settings.allowUnrestricted) {
ctx.logger.warn(
"ACP Runtime: acpAllowUnrestricted is set — sensitive tool calls from the untrusted agent " +
"will be auto-approved under an allow-all policy. Prefer an approval-required policy.",
);
}
},
},
runtime: {

View File

@@ -25,7 +25,7 @@ import {
} from "@agentclientprotocol/sdk";
import { spawnAgent, captureStderr, forceKill, unregisterProcess } from "./process-manager.js";
import { createEventBridge } from "./event-bridge.js";
import { resolvePermission } from "./control-handler.js";
import { resolvePermission, type ResolvePermissionOptions } from "./control-handler.js";
import { createFsHandlers } from "./fs-capabilities.js";
import { boundIdentifier } from "./sanitize.js";
import type { AcpCallbacks, PermissionGate } from "./types.js";
@@ -113,6 +113,7 @@ export function createBridgingClientHandler(
callbacks: AcpCallbacks,
gate?: PermissionGate,
fsOpts?: FsHandlerBuildOptions,
permissionOpts?: ResolvePermissionOptions,
): BridgingClientHandler {
const bridge = createEventBridge(callbacks);
@@ -125,6 +126,7 @@ export function createBridgingClientHandler(
gate,
allowRead: fsOpts.allowRead,
allowWrite: fsOpts.allowWrite,
allowUnrestricted: permissionOpts?.allowUnrestricted,
})
: {};
@@ -165,7 +167,7 @@ export function createBridgingClientHandler(
const drain = (response: RequestPermissionResponse) => finish(response);
pending.add(drain);
resolvePermission(params.toolCall, params.options, gate).then(
resolvePermission(params.toolCall, params.options, gate, permissionOpts).then(
(response) => finish(response),
// resolvePermission never rejects, but stay safe: deny-by-cancel.
() => finish(cancelledResponse),

View File

@@ -69,6 +69,11 @@ export class AcpRuntimeAdapter implements AgentRuntime {
allowRead: this.settings.fsRead,
allowWrite: this.settings.fsWrite,
},
// Risk S1: unless the user acknowledged the untrusted-agent risk, a blanket
// `allow` on a sensitive category is escalated to approval rather than
// auto-approved — so the default `unrestricted` policy can't silently
// green-light this untrusted subprocess.
{ allowUnrestricted: this.settings.allowUnrestricted },
);
// Spawn + initialize (U2). fs capabilities are advertised only where the