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:
66
plugins/fusion-plugin-acp-runtime/README.md
Normal file
66
plugins/fusion-plugin-acp-runtime/README.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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 })({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user