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:
17
.changeset/acp-client-runtime.md
Normal file
17
.changeset/acp-client-runtime.md
Normal 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
66
docs/acp-contract.md
Normal 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.
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
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