diff --git a/.changeset/acp-client-runtime.md b/.changeset/acp-client-runtime.md new file mode 100644 index 0000000000..1d580e1511 --- /dev/null +++ b/.changeset/acp-client-runtime.md @@ -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. diff --git a/docs/acp-contract.md b/docs/acp-contract.md new file mode 100644 index 0000000000..2e40f2cdfa --- /dev/null +++ b/docs/acp-contract.md @@ -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. diff --git a/packages/cli/src/__tests__/bundle-output.test.ts b/packages/cli/src/__tests__/bundle-output.test.ts index 21aefeb9ac..b0ee34f08b 100644 --- a/packages/cli/src/__tests__/bundle-output.test.ts +++ b/packages/cli/src/__tests__/bundle-output.test.ts @@ -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"); diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index fa0d53e9b0..730f509112 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -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", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 817e063ade..581c768372 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -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([ diff --git a/plugins/fusion-plugin-acp-runtime/README.md b/plugins/fusion-plugin-acp-runtime/README.md new file mode 100644 index 0000000000..aab2331b72 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/README.md @@ -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. diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/control-handler.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/control-handler.test.ts index 22724e6590..2887612c81 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/control-handler.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/control-handler.test.ts @@ -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(); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/fs-capabilities.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/fs-capabilities.test.ts index 011f548af6..d21755d0c5 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/fs-capabilities.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/fs-capabilities.test.ts @@ -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 })({ diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts index 2b47eb8937..6a9cda345e 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts @@ -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", () => { diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-permission.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-permission.test.ts index 7977d80763..4b18fcc9c5 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-permission.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-permission.test.ts @@ -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")); diff --git a/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts index e8716d308f..f1aad70cb8 100644 --- a/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts +++ b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts @@ -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): 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 }; } diff --git a/plugins/fusion-plugin-acp-runtime/src/control-handler.ts b/plugins/fusion-plugin-acp-runtime/src/control-handler.ts index d9b2aad26b..066258a73e 100644 --- a/plugins/fusion-plugin-acp-runtime/src/control-handler.ts +++ b/plugins/fusion-plugin-acp-runtime/src/control-handler.ts @@ -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 { // 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)); diff --git a/plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts b/plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts index dda548f903..6422080000 100644 --- a/plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts +++ b/plugins/fusion-plugin-acp-runtime/src/fs-capabilities.ts @@ -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") { diff --git a/plugins/fusion-plugin-acp-runtime/src/index.ts b/plugins/fusion-plugin-acp-runtime/src/index.ts index 3c79b2c16e..057d225865 100644 --- a/plugins/fusion-plugin-acp-runtime/src/index.ts +++ b/plugins/fusion-plugin-acp-runtime/src/index.ts @@ -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: { diff --git a/plugins/fusion-plugin-acp-runtime/src/provider.ts b/plugins/fusion-plugin-acp-runtime/src/provider.ts index 4a4bbaee24..4c5ce9d170 100644 --- a/plugins/fusion-plugin-acp-runtime/src/provider.ts +++ b/plugins/fusion-plugin-acp-runtime/src/provider.ts @@ -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), diff --git a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts index 9c942f1c2d..4e955ab569 100644 --- a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts @@ -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