diff --git a/Makefile b/Makefile index a2d6455..86befd4 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ validate: test-js test-python samples verify-samples test-js: cd packages/js/helm-tool-wrapper && npm install && npm test + cd packages/js/acp-connector && npm install && npm test test-python: python3 -m unittest discover packages/python/helm_tool_wrapper/tests @@ -25,6 +26,8 @@ package: package-js package-python clean: rm -rf packages/js/helm-tool-wrapper/dist rm -rf packages/js/helm-tool-wrapper/node_modules + rm -rf packages/js/acp-connector/dist + rm -rf packages/js/acp-connector/node_modules rm -rf packages/python/helm_tool_wrapper/.pytest_cache rm -rf packages/python/helm_tool_wrapper/helm_tool_wrapper.egg-info rm -rf packages/python/helm_tool_wrapper/build diff --git a/README.md b/README.md index a0d85d2..259c342 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ certification, or production trust anchors. This repo currently ships: - TypeScript `withHelmBoundary(...)` wrapper around `POST /api/v1/evaluate` +- TypeScript `@mindburn/helm-acp-connector` — governed ACP connector for + embedded coding engines (Claude Code, Codex): kernel-verdict permissions, + allowlisted fs handlers, signed+receipted engine provisioning - Python `with_helm_boundary(...)` wrapper around `POST /api/v1/evaluate` - framework intent normalizers for Hermes, OpenClaw, Mastra, Codex, Claude Code, Browser Use, E2B, Composio, and TinyFish governed web capability diff --git a/packages/js/acp-connector/README.md b/packages/js/acp-connector/README.md new file mode 100644 index 0000000..54e21e6 --- /dev/null +++ b/packages/js/acp-connector/README.md @@ -0,0 +1,119 @@ +# @mindburn/helm-acp-connector + +HELM-governed ACP (Agent Client Protocol) connector for embedded coding +engines (Claude Code, Codex). + +The positioning matches this repository: engines propose and orchestrate +work; HELM governs execution and produces evidence. The connector drives +vendor coding agents over ACP (the Zed-originated JSON-RPC protocol) while +routing every sensitive decision through the HELM boundary. + +```text +┌─────────────┐ ACP (ndJSON/JSON-RPC over stdio) ┌──────────────┐ +│ this client │ ◄──────────────────────────────────► │ ACP adapter │──► engine binary +└──────┬──────┘ └──────────────┘ + │ requestPermission ──► Kernel verdict (/api/v1/evaluate), fail-closed + │ fs/read|write_text_file ──► FsGuard declarative allowlist (canonicalized) +``` + +## What it ships + +- **ACP client connector** (`client.ts`) — session lifecycle over a minimal, + self-contained ndJSON JSON-RPC peer (`jsonrpc.ts`, no runtime deps); + 60 s startup deadline on handshake phases only + (`HELM_ACP_STARTUP_TIMEOUT_MS` override); cancel is a protocol + notification; stderr-tail + exit-code error enrichment; handler swapping + for warm-connection reuse. +- **Kernel-gated permission broker** (`permission.ts` + `kernel-evaluator.ts`) — + every `session/request_permission` is routed through a Kernel verdict. + Fail-closed default: DENY / ESCALATE / transport error / timeout all + reject. There is deliberately **no `yolo` policy**. The tiered model is + adapted as a LOW-RISK TIER beneath the heavyweight approval ceremony: + `auto-approve-reads` only classifies read-only tool kinds (read/search/ + fetch/think) as low-risk for the kernel — the kernel still issues the + verdict. Sticky per-session allows are recorded as receipts (decisionId + + receiptId that authorized them). Option-family fallback mapping ensures a + decision always lands on an option the agent actually offered, and a reject + with no reject option offered answers `cancelled` — never an allow. +- **Allowlisted fs handlers** (`fs-guard.ts`) — the counter-position to the + open-fs-handler anti-pattern: `fs/read_text_file` / `fs/write_text_file` + are served only when the requested path canonicalizes inside a declared + root with the required capability. Path canonicalization resolves symlinks + (realpath walk-up for non-existent paths), so a symlink inside an allowed + root pointing outside is denied. One canonical `isPathInside` — a divergent + copy is a permission-bypass risk. The `terminal` capability is never + advertised. +- **Managed engine provisioning client** (`provisioning.ts`) — lockfile-pinned + versions, sha512 (npm SRI) verification, temp-dir extract, atomic rename, + `.meta` ledger, prune-superseded. Plus the HELM additions: + Ed25519-signed manifest enforcement (fail-closed before any download when + trusted keys are configured) and a `ProvisioningReceipt` carrying the + sha512 of the installed binary + manifest digest, persisted per install — + the exact bytes being executed are receipted. Cache hits re-verify the + ledger hash and reprovision on tamper. +- **Session manager** (`manager.ts` + `session-store.ts`) — warm-connection + reuse with an unref'd dispose grace window (default 60 s), + cancel → grace (default 2 s) → force-kill so a wedged adapter can never + lock a turn, per-run session-id persistence with stale-session fallback. + +## Usage sketch + +```ts +import { + AcpSessionManager, SessionStore, FsGuard, HelmKernelEvaluator, + buildAdapterLaunchSpec, ensureEngine, getProvisionedEnginePath, +} from "@mindburn/helm-acp-connector"; + +// 1. Provision the engine (up front — never mid-session). +const { executablePath } = await ensureEngine("claude", { + manifest, manifestSignature, trustedPublicKeys: [releaseKeyPem], +}); + +// 2. Governed session. +const manager = new AcpSessionManager({ + sessionStore: new SessionStore("/var/lib/helm/acp-sessions"), + fsGuard: new FsGuard({ roots: [{ path: "/work/project", read: true, write: true }] }), + evaluator: new HelmKernelEvaluator({ apiKey, tenantId, principal }), + launchSpecFor: (agent) => buildAdapterLaunchSpec({ + agent, adapterEntry: "/path/to/acp-adapter.js", engineExecutablePath: executablePath, + }), +}); + +const result = await manager.runPrompt({ + runId: "run-1", agent: "claude", cwd: "/work/project", + prompt: "fix the failing test", policy: "auto-approve-reads", + onEvent: (e) => console.log(e), +}); +``` + +## Contract notes + +- **Kernel evaluate contract** mirrors `packages/js/helm-tool-wrapper` + (`POST /api/v1/evaluate`, Bearer apiKey, `X-Helm-Tenant-ID` / + `X-Helm-Principal-ID`, `X-Helm-*` receipt headers). Replicated locally to + keep this package build-independent; `helm-ai-kernel` remains the source of + truth for verdict and receipt semantics. +- **Manifest signing contract** (toward helm-desktop / platform-actions): the + engine manifest is expected to be generated in CI from lockfile pins, + signed with the HELM release key (Ed25519 over the canonical manifest + bytes), and shipped with the desktop app. `ProvisioningReceipt` is the + handoff point for EvidencePack assembly on the desktop side. + +## Provenance & licensing + +Design mechanisms adapted from Rowboat (Apache-2.0) with attribution comments +in each module; all code here is original. Deliberately NOT adopted: open fs +handlers (full user FS reach), `yolo` permission policy, unsigned manifests. +No terminal capability is advertised. + +## Tests + +```bash +npm test +``` + +35 tests against a fake ACP agent speaking the real wire protocol: +lifecycle, startup deadline, cancel→grace→force-kill, warm reuse, kernel +verdict round-trips, fail-closed denials, allowlist enforcement including +symlink escape attempts, provisioning hash verification, signed-manifest +enforcement, and tamper-triggered reprovisioning. diff --git a/packages/js/acp-connector/fixtures/fake-acp-agent.mjs b/packages/js/acp-connector/fixtures/fake-acp-agent.mjs new file mode 100644 index 0000000..471d93b --- /dev/null +++ b/packages/js/acp-connector/fixtures/fake-acp-agent.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Fake ACP agent for connector tests. Speaks ndJSON JSON-RPC over stdio. + * Behavior is driven by FAKE_AGENT_BEHAVIOR (JSON env var): + * delayInitMs — delay the initialize response (startup-deadline tests) + * loadSupported — advertise agentCapabilities.loadSession + * requestPermission — on prompt, ask session/request_permission {kind} + * readFilePath — on prompt, issue fs/read_text_file for this path + * writeFilePath — on prompt, issue fs/write_text_file for this path + * hangOnPrompt — never finish the prompt until session/cancel + * ignoreCancel — never respond to the pending prompt even after cancel + * sessionId — fixed session id (default "fake-session-1") + */ +"use strict"; + +const behavior = JSON.parse(process.env.FAKE_AGENT_BEHAVIOR || "{}"); +// pid is embedded so tests can tell a reused warm adapter from a fresh spawn. +const sessionId = (behavior.sessionId || "fake-session") + "-pid" + process.pid; +let nextId = 1; +let buffer = ""; +const pending = new Map(); +let pendingPromptId = null; + +function send(msg) { + process.stdout.write(JSON.stringify(msg) + "\n"); +} +function respond(id, result) { + send({ jsonrpc: "2.0", id, result: result ?? {} }); +} +function respondError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} +function request(method, params) { + const id = nextId++; + send({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); +} +function notify(method, params) { + send({ jsonrpc: "2.0", method, params }); +} + +async function handlePrompt(id, params) { + try { + if (behavior.readFilePath) { + let note; + try { + const res = await request("fs/read_text_file", { sessionId, path: behavior.readFilePath }); + note = `read-ok:${res.content}`; + } catch (err) { + note = `read-denied:${err.message}`; + } + notify("session/update", { + sessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: note } }, + }); + } + if (behavior.writeFilePath) { + let note; + try { + await request("fs/write_text_file", { sessionId, path: behavior.writeFilePath, content: "engine-wrote-this" }); + note = "write-ok"; + } catch (err) { + note = `write-denied:${err.message}`; + } + notify("session/update", { + sessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: note } }, + }); + } + if (behavior.requestPermission) { + const res = await request("session/request_permission", { + sessionId, + toolCall: { toolCallId: "tc-1", title: `${behavior.requestPermission} something`, kind: behavior.requestPermission }, + options: [ + { optionId: "opt-allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "opt-allow-always", name: "Always allow", kind: "allow_always" }, + { optionId: "opt-reject", name: "Reject", kind: "reject_once" }, + ], + }); + const outcome = res.outcome; + notify("session/update", { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `permission:${outcome.outcome}:${outcome.optionId ?? ""}` }, + }, + }); + } + if (behavior.hangOnPrompt) { + pendingPromptId = id; // finished only by session/cancel (or never, if ignoreCancel) + return; + } + notify("session/update", { + sessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "turn-complete" } }, + }); + respond(id, { stopReason: "end_turn" }); + } catch (err) { + respondError(id, -32603, err.message); + } +} + +function handleMessage(msg) { + if (msg.id !== undefined && msg.id !== null && msg.method === undefined) { + // response to one of our requests + const entry = pending.get(msg.id); + if (entry) { + pending.delete(msg.id); + if (msg.error) entry.reject(new Error(msg.error.message)); + else entry.resolve(msg.result); + } + return; + } + switch (msg.method) { + case "initialize": { + const doRespond = () => + respond(msg.id, { + protocolVersion: 1, + agentInfo: { name: "fake-agent", version: "0.0.1" }, + agentCapabilities: { loadSession: behavior.loadSupported === true }, + authMethods: [], + }); + if (behavior.delayInitMs) setTimeout(doRespond, behavior.delayInitMs); + else doRespond(); + return; + } + case "session/new": + respond(msg.id, { sessionId, configOptions: [], models: { availableModels: [] } }); + return; + case "session/load": + if (behavior.loadSupported) respond(msg.id, {}); + else respondError(msg.id, -32601, "session/load not supported"); + return; + case "session/prompt": + void handlePrompt(msg.id, msg.params || {}); + return; + case "session/cancel": + if (pendingPromptId !== null && !behavior.ignoreCancel) { + const id = pendingPromptId; + pendingPromptId = null; + respond(id, { stopReason: "cancelled" }); + } + return; + case "session/set_config_option": + respond(msg.id, { configOptions: [] }); + return; + default: + if (msg.id !== undefined && msg.id !== null) respondError(msg.id, -32601, `unknown method ${msg.method}`); + } +} + +process.stdin.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + for (;;) { + const nl = buffer.indexOf("\n"); + if (nl < 0) return; + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + try { + handleMessage(JSON.parse(line)); + } catch { + /* ignore malformed input */ + } + } +}); + +process.stderr.write("fake-acp-agent ready\n"); diff --git a/packages/js/acp-connector/package-lock.json b/packages/js/acp-connector/package-lock.json new file mode 100644 index 0000000..6bcdda1 --- /dev/null +++ b/packages/js/acp-connector/package-lock.json @@ -0,0 +1,48 @@ +{ + "name": "@mindburn/helm-acp-connector", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mindburn/helm-acp-connector", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^6.0.3" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/js/acp-connector/package.json b/packages/js/acp-connector/package.json new file mode 100644 index 0000000..a4f7a85 --- /dev/null +++ b/packages/js/acp-connector/package.json @@ -0,0 +1,36 @@ +{ + "name": "@mindburn/helm-acp-connector", + "version": "0.1.0", + "description": "HELM-governed ACP (Agent Client Protocol) connector for embedded coding engines (Claude Code, Codex) — kernel-verdict permissions, allowlisted fs handlers, verified engine provisioning", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/index.js", + "dist/index.d.ts", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test \"dist/**/*.test.js\"" + }, + "keywords": [ + "helm", + "ai", + "agents", + "acp", + "coding-agents", + "permissions", + "receipts" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/Mindburn-Labs/helm-agent-integrations", + "directory": "packages/js/acp-connector" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^6.0.3" + } +} diff --git a/packages/js/acp-connector/src/client.test.ts b/packages/js/acp-connector/src/client.test.ts new file mode 100644 index 0000000..9e0c09d --- /dev/null +++ b/packages/js/acp-connector/src/client.test.ts @@ -0,0 +1,252 @@ +/** Lifecycle tests: startup deadline, session lifecycle, stderr enrichment, + * cancel→grace→force-kill, warm-connection reuse — all against a fake ACP + * agent speaking the real wire protocol over stdio. */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { GovernedAcpClient } from "./client.js"; +import { GovernedPermissionBroker } from "./permission.js"; +import { AcpSessionManager } from "./manager.js"; +import { SessionStore } from "./session-store.js"; +import type { AcpRunEvent } from "./types.js"; +import { + FakeKernelEvaluator, + cleanupTmpDir, + fakeLaunchSpec, + guardFor, + makeTmpDir, +} from "./test-utils.js"; + +function makeBroker(evaluator: FakeKernelEvaluator, cwd: string, events?: AcpRunEvent[]): GovernedPermissionBroker { + return new GovernedPermissionBroker({ + evaluator, + policy: "ask", + agent: "claude", + cwd, + onResolved: events + ? (ask, decision, auto, receiptId) => events.push({ type: "permission", ask, decision, auto, receiptId }) + : undefined, + }); +} + +test("lifecycle: start → newSession → prompt → events → dispose", async () => { + const cwd = await makeTmpDir(); + try { + const evaluator = new FakeKernelEvaluator(); + const events: AcpRunEvent[] = []; + const client = new GovernedAcpClient({ + agent: "claude", + cwd, + launchSpec: fakeLaunchSpec({}), + broker: makeBroker(evaluator, cwd), + fsGuard: guardFor(cwd), + onEvent: (e) => events.push(e), + }); + await client.start(); + const sessionId = await client.newSession(); + assert.match(sessionId, /^fake-session-pid\d+$/); + const res = await client.prompt(sessionId, "hello"); + assert.equal(res.stopReason, "end_turn"); + const messages = events.filter((e) => e.type === "message"); + assert.ok(messages.some((m) => m.type === "message" && m.text === "turn-complete")); + client.dispose(); + } finally { + await cleanupTmpDir(cwd); + } +}); + +test("startup deadline: a wedged engine fails loudly instead of pending forever", async () => { + const cwd = await makeTmpDir(); + const prev = process.env.HELM_ACP_STARTUP_TIMEOUT_MS; + process.env.HELM_ACP_STARTUP_TIMEOUT_MS = "300"; + try { + const evaluator = new FakeKernelEvaluator(); + const client = new GovernedAcpClient({ + agent: "claude", + cwd, + launchSpec: fakeLaunchSpec({ delayInitMs: 5000 }), + broker: makeBroker(evaluator, cwd), + fsGuard: guardFor(cwd), + onEvent: () => {}, + }); + await assert.rejects(client.start(), /timed out after 0\.3s/); + client.dispose(); + } finally { + if (prev === undefined) delete process.env.HELM_ACP_STARTUP_TIMEOUT_MS; + else process.env.HELM_ACP_STARTUP_TIMEOUT_MS = prev; + await cleanupTmpDir(cwd); + } +}); + +test("session/load resume with stale-session fallback to session/new", async () => { + const cwd = await makeTmpDir(); + try { + const store = new SessionStore(cwd); + await store.write({ runId: "run-1", agent: "claude", cwd, sessionId: "stale-session" }); + const stored = await store.read("run-1"); + assert.equal(stored?.sessionId, "stale-session"); + await store.clear("run-1"); + assert.equal(await store.read("run-1"), null); + + // Manager: loadSupported agent resumes a stored session id; a stale one falls back. + const evaluator = new FakeKernelEvaluator(); + const manager = new AcpSessionManager({ + sessionStore: store, + fsGuard: guardFor(cwd), + evaluator, + launchSpecFor: () => fakeLaunchSpec({ loadSupported: true }), + disposeGraceMs: 0, + }); + const res = await manager.runPrompt({ + runId: "run-2", + agent: "claude", + cwd, + prompt: "hi", + policy: "ask", + onEvent: () => {}, + }); + assert.match(res.sessionId, /^fake-session-pid\d+$/); + const persisted = await store.read("run-2"); + assert.equal(persisted?.sessionId, res.sessionId); + manager.disposeAll(); + } finally { + await cleanupTmpDir(cwd); + } +}); + +test("cancel → grace → force-kill: a turn that ignores cancel still unwinds", async () => { + const cwd = await makeTmpDir(); + try { + const evaluator = new FakeKernelEvaluator(); + const manager = new AcpSessionManager({ + sessionStore: new SessionStore(cwd), + fsGuard: guardFor(cwd), + evaluator, + launchSpecFor: () => fakeLaunchSpec({ hangOnPrompt: true, ignoreCancel: true }), + disposeGraceMs: 0, + cancelGraceMs: 150, + }); + const controller = new AbortController(); + const started = Date.now(); + const promptP = manager.runPrompt({ + runId: "run-cancel", + agent: "claude", + cwd, + prompt: "hang please", + policy: "ask", + onEvent: () => {}, + signal: controller.signal, + }); + // Give the prompt a moment to reach the agent, then abort. + setTimeout(() => controller.abort(), 200); + const res = await promptP; + assert.equal(res.stopReason, "cancelled"); + assert.ok(Date.now() - started < 3000, "force-kill must bound the unwind"); + manager.disposeAll(); + } finally { + await cleanupTmpDir(cwd); + } +}); + +test("graceful cancel: agent that honors session/cancel resolves cancelled without kill", async () => { + const cwd = await makeTmpDir(); + try { + const evaluator = new FakeKernelEvaluator(); + const manager = new AcpSessionManager({ + sessionStore: new SessionStore(cwd), + fsGuard: guardFor(cwd), + evaluator, + launchSpecFor: () => fakeLaunchSpec({ hangOnPrompt: true, ignoreCancel: false }), + disposeGraceMs: 0, + cancelGraceMs: 2000, + }); + const controller = new AbortController(); + const promptP = manager.runPrompt({ + runId: "run-graceful", + agent: "claude", + cwd, + prompt: "hang please", + policy: "ask", + onEvent: () => {}, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 200); + const res = await promptP; + assert.equal(res.stopReason, "cancelled"); + manager.disposeAll(); + } finally { + await cleanupTmpDir(cwd); + } +}); + +test("warm-connection reuse: back-to-back turns within the grace window share one adapter", async () => { + const cwd = await makeTmpDir(); + try { + const evaluator = new FakeKernelEvaluator(); + const manager = new AcpSessionManager({ + sessionStore: new SessionStore(cwd), + fsGuard: guardFor(cwd), + evaluator, + launchSpecFor: () => fakeLaunchSpec({}), + disposeGraceMs: 30_000, + }); + const first = await manager.runPrompt({ + runId: "run-warm", + agent: "claude", + cwd, + prompt: "one", + policy: "ask", + onEvent: () => {}, + }); + const second = await manager.runPrompt({ + runId: "run-warm", + agent: "claude", + cwd, + prompt: "two", + policy: "ask", + onEvent: () => {}, + }); + // Same session id ⇒ same pid ⇒ the warm adapter was reused, not respawned. + assert.equal(first.sessionId, second.sessionId); + manager.disposeAll(); + } finally { + await cleanupTmpDir(cwd); + } +}); + +test("dispose grace 0: each turn cold-starts a fresh adapter", async () => { + const cwd = await makeTmpDir(); + try { + const evaluator = new FakeKernelEvaluator(); + const manager = new AcpSessionManager({ + sessionStore: new SessionStore(cwd), + fsGuard: guardFor(cwd), + evaluator, + launchSpecFor: () => fakeLaunchSpec({}), + disposeGraceMs: 0, + }); + const first = await manager.runPrompt({ + runId: "run-cold", + agent: "claude", + cwd, + prompt: "one", + policy: "ask", + onEvent: () => {}, + }); + const second = await manager.runPrompt({ + runId: "run-cold", + agent: "claude", + cwd, + prompt: "two", + policy: "ask", + onEvent: () => {}, + }); + // Same stored session id resumed... but with grace 0 the adapter is + // disposed, and the fake agent does not support load, so a NEW session is + // created on a NEW adapter: pids differ. + assert.notEqual(first.sessionId, second.sessionId); + manager.disposeAll(); + } finally { + await cleanupTmpDir(cwd); + } +}); diff --git a/packages/js/acp-connector/src/client.ts b/packages/js/acp-connector/src/client.ts new file mode 100644 index 0000000..06dab21 --- /dev/null +++ b/packages/js/acp-connector/src/client.ts @@ -0,0 +1,345 @@ +/** + * Governed ACP client connector. + * + * Process-lifecycle mechanisms adapted (with attribution, Apache-2.0) from + * Rowboat's code-mode AcpClient (apps/x/packages/core/src/code-mode/acp/ + * client.ts): a startup deadline on the handshake phases only, stderr-tail + + * exit-code error enrichment, and handler swapping on a reused connection. + * Reimplemented on this package's own minimal JSON-RPC peer, and diverging at + * the trust boundary: fs handlers are constrained to a declarative allowlist + * (FsGuard) and every permission request goes to a Kernel verdict — where + * Rowboat serves raw fs on any path and answers permissions from a local + * policy enum. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import type { + AcpRunEvent, + CodingAgent, + InitializeResponse, + NewSessionResponse, + PromptResponse, + ReadTextFileRequest, + RequestPermissionRequest, + SessionNotification, + WriteTextFileRequest, +} from "./types.js"; +import { ACP_PROTOCOL_VERSION } from "./types.js"; +import { NdJsonRpcPeer, JsonRpcError, JSON_RPC_INTERNAL_ERROR, JSON_RPC_METHOD_NOT_FOUND } from "./jsonrpc.js"; +import type { FsGuard } from "./fs-guard.js"; +import type { GovernedPermissionBroker } from "./permission.js"; + +/** How to launch the ACP adapter process for an engine. */ +export interface AdapterLaunchSpec { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; +} + +/** + * Build a launch spec for a vendor ACP adapter, pointing it at a managed + * engine binary. Env-hook mechanism adapted from Rowboat's agents.ts + * (CLAUDE_CODE_EXECUTABLE / CODEX_PATH decouple the engine from the adapter). + */ +export function buildAdapterLaunchSpec(opts: { + agent: CodingAgent; + adapterEntry: string; + engineExecutablePath?: string; + extraEnv?: NodeJS.ProcessEnv; +}): AdapterLaunchSpec { + const env: NodeJS.ProcessEnv = { ...process.env, ...opts.extraEnv }; + if (opts.engineExecutablePath) { + if (opts.agent === "claude") env.CLAUDE_CODE_EXECUTABLE = opts.engineExecutablePath; + if (opts.agent === "codex") env.CODEX_PATH = opts.engineExecutablePath; + } + return { command: process.execPath, args: [opts.adapterEntry], env }; +} + +// Deadline for the startup phases (initialize / session create+load) only. +// A healthy cold start takes seconds; a wedged engine would otherwise pend +// forever. Prompts are intentionally NOT time-limited: turns legitimately run +// for many minutes and may wait on kernel verdicts. Overridable via +// HELM_ACP_STARTUP_TIMEOUT_MS (CI, smoke tests). Read at call time so tests +// can override it per case. +function startupTimeoutMs(): number { + const override = Number(process.env.HELM_ACP_STARTUP_TIMEOUT_MS); + return override > 0 ? override : 60_000; +} + +const STDERR_TAIL_BYTES = 4000; +const STDERR_TAIL_IN_ERROR = 1200; + +export interface GovernedAcpClientOptions { + agent: CodingAgent; + cwd: string; + launchSpec: AdapterLaunchSpec; + broker: GovernedPermissionBroker; + fsGuard: FsGuard; + onEvent: (event: AcpRunEvent) => void; +} + +/** Map a raw session/update notification onto the small run-event union. */ +function toEvent(update: { sessionUpdate?: string; [k: string]: unknown }): AcpRunEvent { + switch (update.sessionUpdate) { + case "agent_message_chunk": + case "user_message_chunk": { + const c = update.content as { type?: string; text?: string } | undefined; + const role = update.sessionUpdate === "user_message_chunk" ? "user" : "agent"; + return { type: "message", role, text: c?.type === "text" ? String(c.text ?? "") : `[${c?.type ?? "unknown"}]` }; + } + case "agent_thought_chunk": + return { type: "thought" }; + case "tool_call": + return { + type: "tool_call", + id: update.toolCallId as string | undefined, + title: update.title as string | undefined, + kind: (update.kind as string | undefined) ?? undefined, + status: (update.status as string | undefined) ?? undefined, + }; + case "tool_call_update": { + const diffs = ((update.content as Array<{ type?: string; path?: string }> | undefined) ?? []) + .filter((c) => c.type === "diff" && typeof c.path === "string") + .map((c) => c.path as string); + return { + type: "tool_call_update", + id: update.toolCallId as string | undefined, + status: (update.status as string | undefined) ?? undefined, + diffs, + }; + } + case "plan": { + const entries = ((update.entries as Array<{ content?: string; status?: string; priority?: string }> | undefined) ?? []) + .map((e) => ({ content: String(e.content ?? ""), status: e.status, priority: e.priority })); + return { type: "plan", entries }; + } + case "usage_update": + return { type: "usage", used: update.used as number | undefined, size: update.size as number | undefined }; + default: + return { type: "other", sessionUpdate: String(update.sessionUpdate ?? "unknown") }; + } +} + +/** + * Owns one spawned adapter process + ACP connection. Stateless about + * sessions — the manager decides whether to session/new or session/load. + */ +export class GovernedAcpClient { + readonly agent: CodingAgent; + readonly cwd: string; + private readonly launchSpec: AdapterLaunchSpec; + private readonly fsGuard: FsGuard; + private broker: GovernedPermissionBroker; + private onEvent: (event: AcpRunEvent) => void; + private child?: ChildProcess; + private peer?: NdJsonRpcPeer; + private loadSession_ = false; + private stderrTail = ""; + private exitInfo: string | null = null; + + constructor(opts: GovernedAcpClientOptions) { + this.agent = opts.agent; + this.cwd = opts.cwd; + this.launchSpec = opts.launchSpec; + this.fsGuard = opts.fsGuard; + this.broker = opts.broker; + this.onEvent = opts.onEvent; + } + + get loadSupported(): boolean { + return this.loadSession_; + } + + /** Re-point the live connection at a new prompt's broker / event sink. */ + setHandlers(broker: GovernedPermissionBroker, onEvent: (event: AcpRunEvent) => void): void { + this.broker = broker; + this.onEvent = onEvent; + } + + /** Spawn the adapter and negotiate the protocol. Returns once initialized. */ + async start(): Promise { + const child = spawn(this.launchSpec.command, this.launchSpec.args, { + cwd: this.cwd, + env: this.launchSpec.env, + stdio: ["pipe", "pipe", "pipe"], + }); + this.child = child; + child.stderr?.on("data", (d: Buffer) => { + this.stderrTail = (this.stderrTail + d.toString()).slice(-STDERR_TAIL_BYTES); + }); + child.on("exit", (code, signal) => { + this.exitInfo = `adapter exited (code ${code}${signal ? `, signal ${signal}` : ""})`; + }); + child.on("error", (err) => { + this.stderrTail = (this.stderrTail + `\nspawn error: ${err.message}`).slice(-STDERR_TAIL_BYTES); + }); + + this.peer = new NdJsonRpcPeer({ input: child.stdout!, output: child.stdin!, label: `${this.agent}-adapter` }); + this.wireIncoming(this.peer); + + try { + const init = await this.withStartupTimeout( + this.peer.request("initialize", { + protocolVersion: ACP_PROTOCOL_VERSION, + clientInfo: { name: "helm-acp-connector", version: "0.1.0" }, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + // terminal capability deliberately NOT advertised: shell execution + // must flow through kernel-gated tool calls, not a client-side + // terminal backdoor. + terminal: false, + }, + }), + ); + this.loadSession_ = init.agentCapabilities?.loadSession === true; + } catch (e) { + throw this.enrich(e, "initialize"); + } + } + + private wireIncoming(peer: NdJsonRpcPeer): void { + const self = this; + peer.on( + "request", + async ( + method: string, + params: unknown, + respond: (result: unknown) => void, + respondError: (err: JsonRpcError) => void, + ) => { + try { + switch (method) { + case "session/request_permission": + respond(await self.broker.resolve(params as RequestPermissionRequest)); + return; + case "fs/read_text_file": + respond(await self.fsGuard.readTextFile(params as ReadTextFileRequest)); + return; + case "fs/write_text_file": + respond(await self.fsGuard.writeTextFile(params as WriteTextFileRequest)); + return; + default: + respondError(new JsonRpcError(JSON_RPC_METHOD_NOT_FOUND, `unsupported method: ${method}`)); + return; + } + } catch (err) { + respondError( + new JsonRpcError( + JSON_RPC_INTERNAL_ERROR, + err instanceof Error ? err.message : String(err), + ), + ); + } + }, + ); + peer.on("notification", (method: string, params: unknown) => { + if (method === "session/update") { + const n = params as SessionNotification; + self.onEvent(toEvent(n.update ?? { sessionUpdate: "unknown" })); + } + }); + } + + /** + * Race a startup-phase request against the deadline so a wedged engine + * fails with a clear, enriched error instead of pending forever. Callers + * dispose the client on failure, killing the spawned adapter. + */ + private async withStartupTimeout(work: Promise): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + `timed out after ${startupTimeoutMs() / 1000}s — the ${this.agent} engine failed to ` + + "complete startup (it may be wedged or misconfigured)", + ), + ); + }, startupTimeoutMs()); + timer.unref?.(); + }); + try { + return await Promise.race([work, timeout]); + } finally { + if (timer) clearTimeout(timer); + } + } + + async newSession(): Promise { + try { + const res = await this.withStartupTimeout( + this.conn().request("session/new", { cwd: this.cwd, mcpServers: [] }), + ); + return res.sessionId; + } catch (e) { + throw this.enrich(e, "newSession"); + } + } + + async loadSession(sessionId: string): Promise { + try { + await this.withStartupTimeout( + this.conn().request("session/load", { sessionId, cwd: this.cwd, mcpServers: [] }), + ); + } catch (e) { + throw this.enrich(e, "loadSession"); + } + } + + /** Read the agent's advertised model/effort options (throwaway session). */ + async describeModelOptions(): Promise { + try { + const res = await this.withStartupTimeout( + this.conn().request("session/new", { cwd: this.cwd, mcpServers: [] }), + ); + return { configOptions: res.configOptions ?? null, models: res.models ?? null }; + } catch (e) { + throw this.enrich(e, "describeModelOptions"); + } + } + + async setSessionConfigOption(sessionId: string, configId: string, value: string): Promise { + await this.conn().request("session/set_config_option", { sessionId, configId, value }); + } + + async prompt(sessionId: string, text: string): Promise { + try { + return await this.conn().request("session/prompt", { + sessionId, + prompt: [{ type: "text", text }], + }); + } catch (e) { + throw this.enrich(e, "prompt"); + } + } + + /** session/cancel is a notification per the ACP spec. */ + async cancel(sessionId: string): Promise { + this.conn().notify("session/cancel", { sessionId }); + } + + /** Wrap a connection error with adapter exit/stderr context. */ + private enrich(err: unknown, phase: string): Error { + const base = err instanceof Error ? err.message : String(err); + const parts = [ + this.exitInfo, + this.stderrTail.trim() ? `adapter output: ${this.stderrTail.trim().slice(-STDERR_TAIL_IN_ERROR)}` : "", + ].filter(Boolean); + return new Error(parts.length ? `${base} — ${parts.join(" | ")} [during ${phase}]` : `${base} [during ${phase}]`); + } + + dispose(): void { + try { + this.child?.kill(); + } catch { + // already gone + } + this.child = undefined; + this.peer = undefined; + } + + private conn(): NdJsonRpcPeer { + if (!this.peer || this.peer.isClosed) throw new Error("ACP client not started"); + return this.peer; + } +} diff --git a/packages/js/acp-connector/src/fs-guard.test.ts b/packages/js/acp-connector/src/fs-guard.test.ts new file mode 100644 index 0000000..2ada6c3 --- /dev/null +++ b/packages/js/acp-connector/src/fs-guard.test.ts @@ -0,0 +1,230 @@ +/** FsGuard tests: allowlist enforcement, .. traversal, symlink escape + * attempts (including final-component and ancestor symlinks), walk-up + * canonicalization for non-existent paths, read/write capability split, and + * end-to-end denial through the ACP wire with the fake agent. */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { FsGuard, FsAccessDeniedError, canonicalizePath, isPathInside } from "./fs-guard.js"; +import { GovernedAcpClient } from "./client.js"; +import { GovernedPermissionBroker } from "./permission.js"; +import type { AcpRunEvent } from "./types.js"; +import { + FakeKernelEvaluator, + cleanupTmpDir, + fakeLaunchSpec, + guardFor, + makeTmpDir, +} from "./test-utils.js"; + +test("isPathInside: the one containment check", () => { + assert.ok(isPathInside("/a/b", "/a/b")); + assert.ok(isPathInside("/a/b", "/a/b/c/d")); + assert.ok(!isPathInside("/a/b", "/a/bc")); + assert.ok(!isPathInside("/a/b", "/a")); + assert.ok(!isPathInside("/a/b", "/etc/passwd")); + assert.ok(!isPathInside("/a/b", "/a/b/../../etc")); +}); + +test("reads inside the allowlist succeed; outside paths deny", async () => { + const root = await makeTmpDir(); + const outside = await makeTmpDir("helm-acp-outside-"); + try { + await fs.writeFile(path.join(root, "inside.txt"), "hello", "utf8"); + await fs.writeFile(path.join(outside, "secret.txt"), "top-secret", "utf8"); + const guard = guardFor(root); + + const ok = await guard.readTextFile({ path: path.join(root, "inside.txt") }); + assert.equal(ok.content, "hello"); + + await assert.rejects( + guard.readTextFile({ path: path.join(outside, "secret.txt") }), + FsAccessDeniedError, + ); + await assert.rejects( + guard.readTextFile({ path: path.join(root, "..", path.basename(outside), "secret.txt") }), + FsAccessDeniedError, + ); + } finally { + await cleanupTmpDir(root); + await cleanupTmpDir(outside); + } +}); + +test("symlink escape: a link inside the allowlist pointing outside is denied", async () => { + const root = await makeTmpDir(); + const outside = await makeTmpDir("helm-acp-outside-"); + try { + await fs.writeFile(path.join(outside, "secret.txt"), "top-secret", "utf8"); + await fs.symlink(path.join(outside, "secret.txt"), path.join(root, "leak.txt")); + const guard = guardFor(root); + await assert.rejects(guard.readTextFile({ path: path.join(root, "leak.txt") }), FsAccessDeniedError); + + // Ancestor-level escape: link a directory out, then a path through it. + await fs.symlink(outside, path.join(root, "outdir")); + await assert.rejects( + guard.readTextFile({ path: path.join(root, "outdir", "secret.txt") }), + FsAccessDeniedError, + ); + await assert.rejects( + guard.writeTextFile({ path: path.join(root, "outdir", "new-file.txt"), content: "x" }), + FsAccessDeniedError, + ); + } finally { + await cleanupTmpDir(root); + await cleanupTmpDir(outside); + } +}); + +test("symlink to an inside target is allowed (canonical location decides)", async () => { + const root = await makeTmpDir(); + try { + await fs.writeFile(path.join(root, "real.txt"), "real-content", "utf8"); + await fs.symlink(path.join(root, "real.txt"), path.join(root, "alias.txt")); + const guard = guardFor(root); + const res = await guard.readTextFile({ path: path.join(root, "alias.txt") }); + assert.equal(res.content, "real-content"); + } finally { + await cleanupTmpDir(root); + } +}); + +test("write capability split: a read-only root denies writes", async () => { + const ro = await makeTmpDir("helm-acp-ro-"); + const rw = await makeTmpDir("helm-acp-rw-"); + try { + await fs.writeFile(path.join(ro, "f.txt"), "data", "utf8"); + const guard = guardFor(rw, [{ path: ro, read: true, write: false }]); + const ok = await guard.readTextFile({ path: path.join(ro, "f.txt") }); + assert.equal(ok.content, "data"); + await assert.rejects( + guard.writeTextFile({ path: path.join(ro, "f.txt"), content: "overwrite" }), + FsAccessDeniedError, + ); + await guard.writeTextFile({ path: path.join(rw, "new.txt"), content: "fine" }); + assert.equal(await fs.readFile(path.join(rw, "new.txt"), "utf8"), "fine"); + } finally { + await cleanupTmpDir(ro); + await cleanupTmpDir(rw); + } +}); + +test("non-existent write target: walk-up canonicalization through a symlinked parent denies escape", async () => { + const root = await makeTmpDir(); + const outside = await makeTmpDir("helm-acp-outside-"); + try { + // A new file under a symlinked dir that points outside must deny. + await fs.symlink(outside, path.join(root, "escape")); + const guard = guardFor(root); + await assert.rejects( + guard.writeTextFile({ path: path.join(root, "escape", "planted.txt"), content: "x" }), + FsAccessDeniedError, + ); + assert.equal( + await fs.stat(path.join(outside, "planted.txt")).then(() => true, () => false), + false, + "no file must be planted outside the allowlist", + ); + + // A new file under a real allowed dir is fine. + await fs.mkdir(path.join(root, "sub")); + await guard.writeTextFile({ path: path.join(root, "sub", "new.txt"), content: "ok" }); + assert.equal(await fs.readFile(path.join(root, "sub", "new.txt"), "utf8"), "ok"); + } finally { + await cleanupTmpDir(root); + await cleanupTmpDir(outside); + } +}); + +test("canonicalizePath resolves /tmp-style symlinked roots (macOS /tmp → /private/tmp)", async () => { + if (process.platform !== "darwin") return; + const canonical = await canonicalizePath("/tmp"); + assert.equal(canonical, "/private/tmp"); +}); + +test("fail-closed construction: no roots is a hard error", () => { + assert.throws(() => new FsGuard({ roots: [] }), /at least one allowlist root/); +}); + +test("end-to-end: engine fs/read_text_file outside the allowlist is denied over the wire", async () => { + const cwd = await makeTmpDir(); + const outside = await makeTmpDir("helm-acp-outside-"); + try { + const secretPath = path.join(outside, "secret.txt"); + await fs.writeFile(secretPath, "top-secret", "utf8"); + const events: AcpRunEvent[] = []; + const client = new GovernedAcpClient({ + agent: "claude", + cwd, + launchSpec: fakeLaunchSpec({ readFilePath: secretPath }), + broker: new GovernedPermissionBroker({ + evaluator: new FakeKernelEvaluator(), + policy: "ask", + agent: "claude", + cwd, + }), + fsGuard: guardFor(cwd), + onEvent: (e) => events.push(e), + }); + await client.start(); + const sessionId = await client.newSession(); + await client.prompt(sessionId, "read my secrets"); + const msg = events.find((e) => e.type === "message"); + assert.ok(msg && msg.type === "message"); + if (msg.type === "message") { + assert.ok(msg.text.startsWith("read-denied:"), `expected denial, got: ${msg.text}`); + assert.ok(msg.text.includes("fs guard"), "denial should come from the fs guard"); + } + client.dispose(); + } finally { + await cleanupTmpDir(cwd); + await cleanupTmpDir(outside); + } +}); + +test("end-to-end: engine fs/write_text_file inside the allowlist succeeds over the wire", async () => { + const cwd = await makeTmpDir(); + try { + const target = path.join(cwd, "engine-output.txt"); + const events: AcpRunEvent[] = []; + const client = new GovernedAcpClient({ + agent: "claude", + cwd, + launchSpec: fakeLaunchSpec({ writeFilePath: target }), + broker: new GovernedPermissionBroker({ + evaluator: new FakeKernelEvaluator(), + policy: "ask", + agent: "claude", + cwd, + }), + fsGuard: guardFor(cwd), + onEvent: (e) => events.push(e), + }); + await client.start(); + const sessionId = await client.newSession(); + await client.prompt(sessionId, "write a file"); + assert.equal(await fs.readFile(target, "utf8"), "engine-wrote-this"); + const msg = events.find((e) => e.type === "message"); + assert.ok(msg && msg.type === "message" && msg.text === "write-ok"); + client.dispose(); + } finally { + await cleanupTmpDir(cwd); + } +}); + +test("no accidental dependency on os.tmpdir quirks", async () => { + // Sanity: guard roots under os.tmpdir() canonicalize consistently. + const root = await makeTmpDir(); + try { + const guard = guardFor(root); + const p = path.join(root, "a.txt"); + await fs.writeFile(p, "x", "utf8"); + const authorized = await guard.authorize(p, "read"); + assert.ok(isPathInside(await canonicalizePath(root), authorized)); + } finally { + await cleanupTmpDir(root); + } +}); diff --git a/packages/js/acp-connector/src/fs-guard.ts b/packages/js/acp-connector/src/fs-guard.ts new file mode 100644 index 0000000..8397d07 --- /dev/null +++ b/packages/js/acp-connector/src/fs-guard.ts @@ -0,0 +1,171 @@ +/** + * Declarative filesystem allowlist for ACP fs handlers. + * + * THE counter-position to Rowboat's central trust-model hole: Rowboat's ACP + * client implements readTextFile/writeTextFile as raw fs calls on any + * absolute path (apps/x/packages/core/src/code-mode/acp/client.ts:341-348), + * giving the spawned engine the user's full filesystem reach. This connector + * fail-closes instead: a path is served only when it canonicalizes inside a + * declared allowlist root with the required capability. + * + * Canonicalization mechanism adapted (with attribution, Apache-2.0) from + * Rowboat's own contained-browsing path (code-mode/projects/fs.ts + * resolveContained) and permission path (filesystem/files.ts + * resolveFilePathForPermission): realpath the target when it exists, and + * realpath-walk-up to the deepest existing ancestor when it does not, so + * symlinked ancestors cannot launder an outside path into an inside one. + * There is exactly ONE containment check here — Rowboat's own code comment + * (filesystem/files.ts) warns that "a divergent copy is a permission-bypass + * risk", and their workspace.ts drifted exactly that way. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; + +export interface FsAllowlistRoot { + /** Absolute (or ~) root directory. Canonicalized at construction. */ + path: string; + read: boolean; + write: boolean; +} + +export interface FsGuardOptions { + roots: FsAllowlistRoot[]; +} + +export type FsCapability = "read" | "write"; + +export class FsAccessDeniedError extends Error { + readonly requestedPath: string; + readonly canonicalPath: string; + readonly capability: FsCapability; + + constructor(requestedPath: string, canonicalPath: string, capability: FsCapability) { + super( + `HELM ACP fs guard: ${capability} denied for ${JSON.stringify(requestedPath)} ` + + `(canonical: ${JSON.stringify(canonicalPath)}) — outside the declarative allowlist`, + ); + this.name = "FsAccessDeniedError"; + this.requestedPath = requestedPath; + this.canonicalPath = canonicalPath; + this.capability = capability; + } +} + +/** THE one containment check. Do not reimplement elsewhere in this package. */ +export function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** + * Canonicalize a possibly-non-existent path: realpath the path itself when it + * exists; otherwise walk up to the deepest existing ancestor, realpath that, + * and re-append the missing tail. A symlink anywhere in the existing prefix + * (including a final-component symlink) resolves to its true target, so the + * subsequent isPathInside check decides on the real location. + */ +export async function canonicalizePath(resolvedPath: string): Promise { + try { + return await fs.realpath(resolvedPath); + } catch { + const missing: string[] = []; + let current = resolvedPath; + const root = path.parse(resolvedPath).root; + while (current !== root) { + try { + const canonicalParent = await fs.realpath(current); + return path.join(canonicalParent, ...missing.reverse()); + } catch { + missing.push(path.basename(current)); + current = path.dirname(current); + } + } + return resolvedPath; + } +} + +interface CanonicalRoot { + canonicalPath: string; + read: boolean; + write: boolean; +} + +/** + * Fail-closed filesystem guard. Every engine fs request is canonicalized and + * checked against the declared roots; anything outside (or lacking the + * capability) is denied before any disk access happens. + */ +export class FsGuard { + private rootsPromise: Promise | null = null; + private readonly declaredRoots: FsAllowlistRoot[]; + + constructor(opts: FsGuardOptions) { + if (!opts.roots || opts.roots.length === 0) { + throw new Error("FsGuard requires at least one allowlist root (fail-closed: no implicit roots)"); + } + this.declaredRoots = opts.roots; + } + + /** Lazily canonicalize the declared roots once per process. */ + private roots(): Promise { + if (!this.rootsPromise) { + this.rootsPromise = Promise.all( + this.declaredRoots.map(async (r) => ({ + canonicalPath: await canonicalizePath(path.resolve(r.path)), + read: r.read, + write: r.write, + })), + ); + } + return this.rootsPromise; + } + + /** + * Resolve and authorize a path for the given capability. Returns the + * canonical absolute path to operate on. Throws FsAccessDeniedError when + * no allowlist root grants the capability at the canonical location. + */ + async authorize(requestedPath: string, capability: FsCapability): Promise { + if (typeof requestedPath !== "string" || requestedPath.trim() === "") { + throw new FsAccessDeniedError(String(requestedPath), "", capability); + } + const resolved = path.resolve(requestedPath); + const canonical = await canonicalizePath(resolved); + const roots = await this.roots(); + for (const root of roots) { + if (capability === "read" && !root.read) continue; + if (capability === "write" && !root.write) continue; + if (isPathInside(root.canonicalPath, canonical)) { + return canonical; + } + } + throw new FsAccessDeniedError(requestedPath, canonical, capability); + } + + /** ACP fs/read_text_file handler. Supports optional line/limit paging. */ + async readTextFile(params: { path: string; line?: number | null; limit?: number | null }): Promise<{ content: string }> { + const canonical = await this.authorize(params.path, "read"); + const stat = await fs.stat(canonical); + if (!stat.isFile()) { + throw new Error(`Not a file: ${params.path}`); + } + const raw = await fs.readFile(canonical, "utf8"); + const line = params.line ?? null; + const limit = params.limit ?? null; + if (line === null && limit === null) { + return { content: raw }; + } + const lines = raw.split("\n"); + const start = Math.max(0, (line ?? 1) - 1); + const slice = limit === null ? lines.slice(start) : lines.slice(start, start + Math.max(0, limit)); + return { content: slice.join("\n") }; + } + + /** ACP fs/write_text_file handler. */ + async writeTextFile(params: { path: string; content: string }): Promise> { + const canonical = await this.authorize(params.path, "write"); + await fs.writeFile(canonical, params.content, "utf8"); + return {}; + } +} diff --git a/packages/js/acp-connector/src/index.ts b/packages/js/acp-connector/src/index.ts new file mode 100644 index 0000000..045e0c4 --- /dev/null +++ b/packages/js/acp-connector/src/index.ts @@ -0,0 +1,9 @@ +export * from "./types.js"; +export * from "./jsonrpc.js"; +export * from "./fs-guard.js"; +export * from "./kernel-evaluator.js"; +export * from "./permission.js"; +export * from "./client.js"; +export * from "./session-store.js"; +export * from "./manager.js"; +export * from "./provisioning.js"; diff --git a/packages/js/acp-connector/src/jsonrpc.ts b/packages/js/acp-connector/src/jsonrpc.ts new file mode 100644 index 0000000..bc21dd6 --- /dev/null +++ b/packages/js/acp-connector/src/jsonrpc.ts @@ -0,0 +1,169 @@ +/** + * Minimal ndJSON JSON-RPC 2.0 peer for ACP over a child process's stdio. + * + * Original implementation for the HELM ACP connector. ACP itself is the + * Zed-originated JSON-RPC protocol; this peer only implements what the + * connector needs: outgoing requests/notifications, incoming requests + * dispatched to method handlers, incoming notifications, and line-buffered + * framing. Deliberately small: fewer moving parts behind the HELM boundary. + */ + +import { EventEmitter } from "node:events"; +import type { Readable, Writable } from "node:stream"; + +export class JsonRpcError extends Error { + readonly code: number; + readonly data?: unknown; + + constructor(code: number, message: string, data?: unknown) { + super(message); + this.name = "JsonRpcError"; + this.code = code; + this.data = data; + } +} + +export const JSON_RPC_METHOD_NOT_FOUND = -32601; +export const JSON_RPC_INTERNAL_ERROR = -32603; +export const JSON_RPC_CANCELLED = -32800; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (err: Error) => void; +}; + +export type IncomingRequestHandler = (params: unknown) => Promise; +export type NotificationHandler = (params: unknown) => void; + +export interface NdJsonRpcPeerOptions { + input: Readable; + output: Writable; + /** Human label used in error messages (e.g. "claude adapter"). */ + label?: string; +} + +/** + * A bidirectional JSON-RPC peer framed as newline-delimited JSON. + * Events: + * - "request" (method, params, respond) — agent → client requests + * - "notification" (method, params) — agent → client notifications + * - "closed" — the input stream ended; all pending requests reject + */ +export class NdJsonRpcPeer extends EventEmitter { + private readonly input: Readable; + private readonly output: Writable; + private readonly label: string; + private nextId = 1; + private readonly pending = new Map(); + private buffer = ""; + private closed = false; + + constructor(opts: NdJsonRpcPeerOptions) { + super(); + this.input = opts.input; + this.output = opts.output; + this.label = opts.label ?? "acp-peer"; + this.input.on("data", (chunk: Buffer) => this.onData(chunk)); + this.input.on("end", () => this.onClosed()); + this.input.on("error", () => this.onClosed()); + } + + get isClosed(): boolean { + return this.closed; + } + + /** Send a request and await its response. */ + request(method: string, params?: unknown): Promise { + if (this.closed) { + return Promise.reject(new Error(`${this.label}: connection closed`)); + } + const id = this.nextId++; + const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} }); + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (v) => resolve(v as T), + reject, + }); + this.output.write(payload + "\n", (err) => { + if (err) { + this.pending.delete(id); + reject(new Error(`${this.label}: write failed — ${err.message}`)); + } + }); + }); + } + + /** Send a notification (no response expected). */ + notify(method: string, params?: unknown): void { + if (this.closed) return; + const payload = JSON.stringify({ jsonrpc: "2.0", method, params: params ?? {} }); + this.output.write(payload + "\n"); + } + + private onData(chunk: Buffer): void { + this.buffer += chunk.toString("utf8"); + for (;;) { + const nl = this.buffer.indexOf("\n"); + if (nl < 0) return; + const line = this.buffer.slice(0, nl).trim(); + this.buffer = this.buffer.slice(nl + 1); + if (line.length > 0) this.onLine(line); + } + } + + private onLine(line: string): void { + let msg: Record; + try { + msg = JSON.parse(line) as Record; + } catch { + return; // ignore non-JSON noise on stdout + } + const id = msg.id; + const method = msg.method; + + if (typeof method === "string") { + if (id !== undefined && id !== null) { + // Incoming request — dispatch and respond. + const respond = (result: unknown): void => { + this.output.write(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }) + "\n"); + }; + const respondError = (err: JsonRpcError): void => { + this.output.write( + JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code: err.code, message: err.message, ...(err.data !== undefined ? { data: err.data } : {}) }, + }) + "\n", + ); + }; + this.emit("request", method, msg.params ?? {}, respond, respondError); + } else { + this.emit("notification", method, msg.params ?? {}); + } + return; + } + + if (id !== undefined && id !== null && typeof id === "number") { + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + const err = msg.error as { code?: number; message?: string; data?: unknown } | undefined; + if (err) { + entry.reject(new JsonRpcError(err.code ?? JSON_RPC_INTERNAL_ERROR, err.message ?? "unknown error", err.data)); + } else { + entry.resolve(msg.result); + } + } + } + + private onClosed(): void { + if (this.closed) return; + this.closed = true; + const pending = [...this.pending.values()]; + this.pending.clear(); + for (const p of pending) { + p.reject(new Error(`${this.label}: connection closed`)); + } + this.emit("closed"); + } +} diff --git a/packages/js/acp-connector/src/kernel-evaluator.ts b/packages/js/acp-connector/src/kernel-evaluator.ts new file mode 100644 index 0000000..ee0465f --- /dev/null +++ b/packages/js/acp-connector/src/kernel-evaluator.ts @@ -0,0 +1,191 @@ +/** + * Kernel verdict client for ACP permission decisions. + * + * CONTRACT NOTE: the HTTP shape below intentionally mirrors the HELM + * tenant-scoped evaluate endpoint as implemented in this repo's + * packages/js/helm-tool-wrapper (POST {helmUrl}/api/v1/evaluate with Bearer + * apiKey, X-Helm-Tenant-ID / X-Helm-Principal-ID headers, receipt metadata on + * X-Helm-* response headers). It is replicated locally so this package stays + * build-independent; helm-ai-kernel remains the source of truth for verdict + * and receipt semantics. If the kernel contract changes, both packages must + * be updated together. + * + * Fail-closed doctrine: this module NEVER fabricates an ALLOW. Any transport + * error, timeout, malformed response, or non-ALLOW verdict is surfaced to the + * caller, which must treat it as a rejection. + */ + +import type { PermissionAsk } from "./types.js"; + +export type KernelVerdictValue = "ALLOW" | "DENY" | "ESCALATE" | "PENDING" | string; + +export interface KernelVerdict { + verdict: KernelVerdictValue; + reason?: string; + reasonCode?: string; + decisionId?: string; + receiptId?: string; + /** Kernel hint that this allow may stick for the session (low-risk tier). */ + stickyAllow?: boolean; + raw?: unknown; +} + +export interface KernelEvaluationRequest { + ask: PermissionAsk; + /** Low-risk interactive tier: read-only tool kinds under an + * auto-approve-reads policy. Beneath the heavyweight approval ceremony; + * the kernel still issues the verdict — the tier only classifies. */ + tier: "low" | "standard"; + agent: string; + cwd: string; + policy: string; +} + +export interface KernelEvaluator { + evaluate(request: KernelEvaluationRequest): Promise; +} + +export type FetchLike = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; + }, +) => Promise<{ + ok: boolean; + status: number; + statusText?: string; + headers: { get(name: string): string | null }; + json(): Promise; + text(): Promise; +}>; + +export interface HelmKernelEvaluatorOptions { + apiKey: string; + tenantId: string; + principal: string; + helmUrl?: string; + timeoutMs?: number; + fetch?: FetchLike; +} + +const DEFAULT_HELM_URL = "http://127.0.0.1:7714"; + +/** Tool kinds that never mutate state — candidates for the low-risk tier. */ +export const READ_TOOL_KINDS = new Set(["read", "search", "fetch", "think"]); + +/** Advisory risk/effect classes (kernel-owned taxonomy T0–T3 / E0–E4). */ +export function classifyAsk(ask: PermissionAsk, tier: "low" | "standard"): { riskClass: string; effectClass: string } { + if (tier === "low") return { riskClass: "T0", effectClass: "E1" }; + const kind = ask.kind ?? ""; + if (kind === "execute" || kind === "shell") return { riskClass: "T2", effectClass: "E3" }; + if (kind === "edit" || kind === "write" || kind === "delete" || kind === "move") { + return { riskClass: "T2", effectClass: "E2" }; + } + return { riskClass: "T1", effectClass: "E2" }; +} + +function readRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function str(value: unknown): string | undefined { + return typeof value === "string" && value !== "" ? value : undefined; +} + +/** + * Kernel evaluator over the HELM evaluate HTTP endpoint. Mirrors the + * helm-tool-wrapper preflight contract (see CONTRACT NOTE above). + */ +export class HelmKernelEvaluator implements KernelEvaluator { + private readonly opts: HelmKernelEvaluatorOptions; + + constructor(opts: HelmKernelEvaluatorOptions) { + if (!opts.apiKey?.trim()) { + throw new Error("HELM apiKey is required for the ACP permission evaluator (fail-closed)"); + } + if (!opts.tenantId?.trim() || !opts.principal?.trim()) { + throw new Error("HELM tenantId and principal are required for the ACP permission evaluator"); + } + this.opts = opts; + } + + async evaluate(request: KernelEvaluationRequest): Promise { + const fetchImpl = this.opts.fetch ?? (globalThis.fetch as FetchLike | undefined); + if (!fetchImpl) { + throw new Error("No fetch implementation available for the HELM evaluator"); + } + const { riskClass, effectClass } = classifyAsk(request.ask, request.tier); + const actionUrn = `tool.acp.${request.agent}.${request.ask.kind ?? "unknown"}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs ?? 10_000); + const payload = { + principal: this.opts.principal, + action: "EXECUTE_TOOL", + resource: actionUrn, + context: { + tool: actionUrn, + args: { + title: request.ask.title, + kind: request.ask.kind, + tool_call_id: request.ask.toolCallId, + cwd: request.cwd, + }, + agent_id: this.opts.principal, + effect_level: effectClass, + session_id: request.ask.sessionId, + action_urn: actionUrn, + risk_class: riskClass, + effect_class: effectClass, + metadata: { + framework: "acp", + connector_id: "helm-acp-connector", + engine: request.agent, + permission_tier: request.tier, + policy: request.policy, + }, + }, + }; + try { + const base = (this.opts.helmUrl ?? DEFAULT_HELM_URL).replace(/\/$/, ""); + const response = await fetchImpl(`${base}/api/v1/evaluate`, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.opts.apiKey}`, + "Content-Type": "application/json", + "X-Helm-Tenant-ID": this.opts.tenantId, + "X-Helm-Principal-ID": this.opts.principal, + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + let body: unknown; + try { + body = await response.json(); + } catch { + body = await response.text(); + } + if (!response.ok) { + throw new Error(`HELM evaluate failed with HTTP ${response.status}`); + } + const rec = readRecord(body); + const nested = readRecord(rec.decision ?? rec.record ?? rec.result ?? body); + const verdictRaw = nested.verdict ?? nested.status ?? rec.verdict ?? rec.status; + const verdict = typeof verdictRaw === "string" ? verdictRaw.toUpperCase() : "DENY"; + return { + verdict, + reason: str(nested.reason) ?? str(rec.reason), + reasonCode: str(nested.reason_code) ?? str(rec.reason_code), + decisionId: str(nested.decision_id) ?? str(rec.decision_id) ?? str(nested.id), + receiptId: + response.headers.get("x-helm-receipt-id") ?? str(nested.receipt_id) ?? str(rec.receipt_id) ?? undefined, + stickyAllow: nested.sticky_allow === true || rec.sticky_allow === true, + raw: body, + }; + } finally { + clearTimeout(timer); + } + } +} diff --git a/packages/js/acp-connector/src/manager.ts b/packages/js/acp-connector/src/manager.ts new file mode 100644 index 0000000..7131609 --- /dev/null +++ b/packages/js/acp-connector/src/manager.ts @@ -0,0 +1,239 @@ +/** + * ACP session manager: warm-connection reuse, run termination, session resume. + * + * Lifecycle mechanisms adapted (with attribution, Apache-2.0) from Rowboat's + * CodeModeManager (apps/x/packages/core/src/code-mode/acp/manager.ts): + * cancel → grace → force-kill so a wedged adapter can never lock a turn + * indefinitely, warm-connection reuse with an unref'd dispose grace window, + * and resume-via-load with a stale-session fallback. Reimplemented here with + * the governed client (kernel-gated permissions + allowlisted fs). + */ + +import { GovernedAcpClient, type AdapterLaunchSpec } from "./client.js"; +import { GovernedPermissionBroker } from "./permission.js"; +import type { KernelEvaluator } from "./kernel-evaluator.js"; +import type { FsGuard } from "./fs-guard.js"; +import { SessionStore } from "./session-store.js"; +import type { + AcpRunEvent, + ApprovalPolicy, + CodingAgent, + RunPromptResult, +} from "./types.js"; + +export interface RunPromptArgs { + runId: string; + agent: CodingAgent; + cwd: string; + prompt: string; + policy: ApprovalPolicy; + /** Stream sink for this prompt's run. */ + onEvent: (event: AcpRunEvent) => void; + /** Aborts the turn on stop; the manager cancels then force-kills. */ + signal?: AbortSignal; + /** Model alias/id applied best-effort before the prompt. */ + model?: string; + /** Reasoning effort applied best-effort alongside the model. */ + effort?: string; +} + +export interface AcpSessionManagerOptions { + sessionStore: SessionStore; + fsGuard: FsGuard; + evaluator: KernelEvaluator; + /** Launch-spec factory (engine resolution happens outside the manager). */ + launchSpecFor: (agent: CodingAgent, cwd: string) => AdapterLaunchSpec; + /** Warm-connection grace window after the last turn ends (default 60 s). */ + disposeGraceMs?: number; + /** Grace between session/cancel and force-kill on stop (default 2 s). */ + cancelGraceMs?: number; +} + +interface ActiveRun { + client: GovernedAcpClient; + sessionId: string; + agent: CodingAgent; + cwd: string; + inflight: number; + disposeTimer?: ReturnType; +} + +const DEFAULT_DISPOSE_GRACE_MS = 60_000; +const DEFAULT_CANCEL_GRACE_MS = 2_000; + +export class AcpSessionManager { + private readonly opts: AcpSessionManagerOptions; + private readonly runs = new Map(); + + constructor(opts: AcpSessionManagerOptions) { + this.opts = opts; + } + + async runPrompt(args: RunPromptArgs): Promise { + const { runId, agent, cwd, prompt, policy, onEvent, signal } = args; + + const broker = new GovernedPermissionBroker({ + evaluator: this.opts.evaluator, + policy, + agent, + cwd, + onResolved: (ask, decision, auto, receiptId) => + onEvent({ type: "permission", ask, decision, auto, receiptId }), + }); + + const run = await this.ensureRun(runId, agent, cwd, broker, onEvent); + await this.applyModelAndEffort(run, args.model, args.effort); + run.inflight++; + + const cancelGraceMs = this.opts.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS; + let graceTimer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + const promptP = run.client.prompt(run.sessionId, prompt); + // We may stop awaiting this prompt below (force-kill rejects it); + // attach a no-op catch so the orphaned rejection isn't flagged. + promptP.catch(() => {}); + + // Stop handling: on abort, send session/cancel; if the adapter hasn't + // unwound within the grace, force-kill it and resolve as cancelled. + // This guarantees the turn ends even if the adapter ignores cancel. + const cancelledP = new Promise<{ stopReason: string }>((resolve) => { + if (!signal) return; + onAbort = () => { + run.client.cancel(run.sessionId).catch(() => {}); + graceTimer = setTimeout(() => { + this.dispose(runId); + resolve({ stopReason: "cancelled" }); + }, cancelGraceMs); + graceTimer.unref?.(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }); + + const res = await Promise.race([promptP, cancelledP]); + return { stopReason: res.stopReason, sessionId: run.sessionId }; + } catch (e) { + // A kill-induced "connection closed" during a stop is an expected cancel. + if (signal?.aborted) return { stopReason: "cancelled", sessionId: run.sessionId }; + throw e; + } finally { + if (signal && onAbort) signal.removeEventListener("abort", onAbort); + if (graceTimer) clearTimeout(graceTimer); + run.inflight--; + this.scheduleDispose(runId); + } + } + + /** Model/effort application is best-effort: a bad value must never block a turn. */ + private async applyModelAndEffort(run: ActiveRun, model?: string, effort?: string): Promise { + if (model && model !== "default") { + try { + await run.client.setSessionConfigOption(run.sessionId, "model", model); + } catch { + // warn-and-continue: engine default applies + } + } + if (effort && effort !== "default") { + try { + await run.client.setSessionConfigOption(run.sessionId, "effort", effort); + } catch { + // warn-and-continue + } + } + } + + dispose(runId: string): void { + const run = this.runs.get(runId); + if (!run) return; + this.cancelDispose(run); + run.client.dispose(); + this.runs.delete(runId); + } + + /** Tear down the connection a grace window after its last turn ends. */ + private scheduleDispose(runId: string): void { + const run = this.runs.get(runId); + if (!run || run.inflight > 0) return; + this.cancelDispose(run); + const graceMs = this.opts.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS; + if (graceMs <= 0) { + this.dispose(runId); + return; + } + run.disposeTimer = setTimeout(() => { + const r = this.runs.get(runId); + if (r && r.inflight === 0) this.dispose(runId); + }, graceMs); + run.disposeTimer.unref?.(); + } + + private cancelDispose(run: ActiveRun): void { + if (run.disposeTimer) { + clearTimeout(run.disposeTimer); + run.disposeTimer = undefined; + } + } + + disposeAll(): void { + for (const runId of [...this.runs.keys()]) this.dispose(runId); + } + + /** Reuse the warm connection if it matches; otherwise build a fresh one. */ + private async ensureRun( + runId: string, + agent: CodingAgent, + cwd: string, + broker: GovernedPermissionBroker, + onEvent: (event: AcpRunEvent) => void, + ): Promise { + const existing = this.runs.get(runId); + if (existing && existing.agent === agent && existing.cwd === cwd) { + this.cancelDispose(existing); + existing.client.setHandlers(broker, onEvent); + return existing; + } + if (existing) this.dispose(runId); + + const client = new GovernedAcpClient({ + agent, + cwd, + launchSpec: this.opts.launchSpecFor(agent, cwd), + broker, + fsGuard: this.opts.fsGuard, + onEvent, + }); + try { + await client.start(); + const sessionId = await this.openSession(runId, agent, cwd, client); + const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 }; + this.runs.set(runId, run); + return run; + } catch (e) { + client.dispose(); + throw e; + } + } + + /** Resume the persisted session when possible; else start a fresh one. */ + private async openSession( + runId: string, + agent: CodingAgent, + cwd: string, + client: GovernedAcpClient, + ): Promise { + const stored = await this.opts.sessionStore.read(runId); + if (stored && stored.agent === agent && stored.cwd === cwd && client.loadSupported) { + try { + await client.loadSession(stored.sessionId); + return stored.sessionId; + } catch { + // Stored session is stale/unloadable — fall through to a fresh one. + await this.opts.sessionStore.clear(runId); + } + } + const sessionId = await client.newSession(); + await this.opts.sessionStore.write({ runId, agent, cwd, sessionId }); + return sessionId; + } +} diff --git a/packages/js/acp-connector/src/permission.test.ts b/packages/js/acp-connector/src/permission.test.ts new file mode 100644 index 0000000..65b0e4f --- /dev/null +++ b/packages/js/acp-connector/src/permission.test.ts @@ -0,0 +1,212 @@ +/** Permission broker tests: kernel verdict round-trips, fail-closed defaults, + * low-risk tier semantics, sticky allows recorded as receipts, option-family + * fallback mapping. */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { GovernedPermissionBroker, pickPermissionOption } from "./permission.js"; +import { GovernedAcpClient } from "./client.js"; +import type { AcpRunEvent, RequestPermissionRequest } from "./types.js"; +import { + FakeKernelEvaluator, + cleanupTmpDir, + fakeLaunchSpec, + guardFor, + makeTmpDir, +} from "./test-utils.js"; + +const OPTIONS = [ + { optionId: "opt-allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "opt-allow-always", name: "Always allow", kind: "allow_always" }, + { optionId: "opt-reject", name: "Reject", kind: "reject_once" }, +]; + +function permRequest(kind: string): RequestPermissionRequest { + return { + sessionId: "sess-1", + toolCall: { toolCallId: "tc-1", title: `${kind} something`, kind }, + options: OPTIONS, + }; +} + +function makeBroker( + evaluator: FakeKernelEvaluator, + policy: "ask" | "auto-approve-reads" = "ask", +): GovernedPermissionBroker { + return new GovernedPermissionBroker({ evaluator, policy, agent: "claude", cwd: "/tmp" }); +} + +test("ALLOW verdict maps to an offered allow option", async () => { + const evaluator = new FakeKernelEvaluator(); + const broker = makeBroker(evaluator); + const res = await broker.resolve(permRequest("edit")); + assert.deepEqual(res.outcome, { outcome: "selected", optionId: "opt-allow-once" }); + assert.equal(evaluator.calls.length, 1); + assert.equal(evaluator.calls[0].tier, "standard"); +}); + +test("DENY verdict maps to a reject option (fail-closed)", async () => { + const evaluator = new FakeKernelEvaluator(); + evaluator.defaultVerdict = { verdict: "DENY", reasonCode: "POLICY_DENY" }; + const broker = makeBroker(evaluator); + const res = await broker.resolve(permRequest("edit")); + assert.deepEqual(res.outcome, { outcome: "selected", optionId: "opt-reject" }); +}); + +test("ESCALATE verdict rejects at this tier — heavyweight ceremony is elsewhere", async () => { + const evaluator = new FakeKernelEvaluator(); + evaluator.defaultVerdict = { verdict: "ESCALATE" }; + const broker = makeBroker(evaluator); + const res = await broker.resolve(permRequest("edit")); + assert.deepEqual(res.outcome, { outcome: "selected", optionId: "opt-reject" }); +}); + +test("evaluator transport failure rejects (fail-closed, never a silent allow)", async () => { + const evaluator = new FakeKernelEvaluator(); + evaluator.throwError = new Error("connection refused"); + const broker = makeBroker(evaluator); + const res = await broker.resolve(permRequest("edit")); + assert.deepEqual(res.outcome, { outcome: "selected", optionId: "opt-reject" }); +}); + +test("auto-approve-reads: read kinds take the low-risk tier and stick with a receipt", async () => { + const evaluator = new FakeKernelEvaluator(); + const stickies: unknown[] = []; + const broker = new GovernedPermissionBroker({ + evaluator, + policy: "auto-approve-reads", + agent: "claude", + cwd: "/tmp", + onStickyAllow: (r) => stickies.push(r), + }); + + const first = await broker.resolve(permRequest("read")); + assert.deepEqual(first.outcome, { outcome: "selected", optionId: "opt-allow-always" }); + assert.equal(evaluator.calls.length, 1); + assert.equal(evaluator.calls[0].tier, "low"); + assert.equal(stickies.length, 1); + assert.deepEqual( + broker.stickyAllowReceipts().map((r) => r.receiptId), + ["rcpt-fake"], + ); + + // Second identical ask resolves from sticky memory WITHOUT a new kernel call. + const second = await broker.resolve(permRequest("read")); + assert.deepEqual(second.outcome, { outcome: "selected", optionId: "opt-allow-always" }); + assert.equal(evaluator.calls.length, 1); +}); + +test("auto-approve-reads does NOT extend to mutating kinds (standard tier, no stick)", async () => { + const evaluator = new FakeKernelEvaluator(); + const broker = makeBroker(evaluator, "auto-approve-reads"); + const res = await broker.resolve(permRequest("edit")); + assert.deepEqual(res.outcome, { outcome: "selected", optionId: "opt-allow-once" }); + assert.equal(evaluator.calls[0].tier, "standard"); + assert.equal(broker.stickyAllowReceipts().length, 0); + // A second ask hits the kernel again — no sticky record for standard tier. + await broker.resolve(permRequest("edit")); + assert.equal(evaluator.calls.length, 2); +}); + +test("kernel stickyAllow hint sticks under the plain ask policy too, with receipt", async () => { + const evaluator = new FakeKernelEvaluator(); + evaluator.defaultVerdict = { verdict: "ALLOW", receiptId: "rcpt-sticky", decisionId: "dec-1", stickyAllow: true }; + const broker = makeBroker(evaluator, "ask"); + const res = await broker.resolve(permRequest("edit")); + assert.deepEqual(res.outcome, { outcome: "selected", optionId: "opt-allow-always" }); + assert.equal(broker.stickyAllowReceipts()[0]?.receiptId, "rcpt-sticky"); + await broker.resolve(permRequest("edit")); + assert.equal(evaluator.calls.length, 1); +}); + +test("option-family fallback: allow maps to allow_once when allow_always is not offered", () => { + const opt = pickPermissionOption( + [ + { optionId: "a1", kind: "allow_once" }, + { optionId: "r1", kind: "reject_once" }, + ], + "allow_always", + ); + assert.equal(opt?.optionId, "a1"); + const rej = pickPermissionOption([{ optionId: "a1", kind: "allow_once" }], "reject"); + assert.equal(rej, undefined); +}); + +test("reject with no reject option offered answers cancelled, never an allow", async () => { + const evaluator = new FakeKernelEvaluator(); + evaluator.defaultVerdict = { verdict: "DENY" }; + const broker = makeBroker(evaluator); + const res = await broker.resolve({ + sessionId: "s", + toolCall: { kind: "edit", title: "x" }, + options: [{ optionId: "only-allow", kind: "allow_once" }], + }); + assert.deepEqual(res.outcome, { outcome: "cancelled" }); +}); + +test("end-to-end: fake agent's requestPermission round-trips through the kernel", async () => { + const cwd = await makeTmpDir(); + try { + const evaluator = new FakeKernelEvaluator(); + const events: AcpRunEvent[] = []; + const client = new GovernedAcpClient({ + agent: "claude", + cwd, + launchSpec: fakeLaunchSpec({ requestPermission: "read" }), + broker: new GovernedPermissionBroker({ + evaluator, + policy: "auto-approve-reads", + agent: "claude", + cwd, + onResolved: (ask, decision, auto, receiptId) => + events.push({ type: "permission", ask, decision, auto, receiptId }), + }), + fsGuard: guardFor(cwd), + onEvent: (e) => events.push(e), + }); + await client.start(); + const sessionId = await client.newSession(); + const res = await client.prompt(sessionId, "do a read"); + assert.equal(res.stopReason, "end_turn"); + + const permEvents = events.filter((e) => e.type === "permission"); + assert.equal(permEvents.length, 1); + const p = permEvents[0]; + assert.ok(p.type === "permission"); + if (p.type === "permission") { + assert.equal(p.decision, "allow_always"); + assert.equal(p.receiptId, "rcpt-fake"); + assert.equal(p.ask.kind, "read"); + } + const msg = events.find((e) => e.type === "message" && e.text.startsWith("permission:")); + assert.ok(msg && msg.type === "message" && msg.text === "permission:selected:opt-allow-always"); + client.dispose(); + } finally { + await cleanupTmpDir(cwd); + } +}); + +test("end-to-end: kernel DENY reaches the agent as a rejection", async () => { + const cwd = await makeTmpDir(); + try { + const evaluator = new FakeKernelEvaluator(); + evaluator.defaultVerdict = { verdict: "DENY", reasonCode: "NO" }; + const events: AcpRunEvent[] = []; + const client = new GovernedAcpClient({ + agent: "claude", + cwd, + launchSpec: fakeLaunchSpec({ requestPermission: "edit" }), + broker: new GovernedPermissionBroker({ evaluator, policy: "ask", agent: "claude", cwd }), + fsGuard: guardFor(cwd), + onEvent: (e) => events.push(e), + }); + await client.start(); + const sessionId = await client.newSession(); + await client.prompt(sessionId, "edit something"); + const msg = events.find((e) => e.type === "message" && e.text.startsWith("permission:")); + assert.ok(msg && msg.type === "message" && msg.text === "permission:selected:opt-reject"); + client.dispose(); + } finally { + await cleanupTmpDir(cwd); + } +}); diff --git a/packages/js/acp-connector/src/permission.ts b/packages/js/acp-connector/src/permission.ts new file mode 100644 index 0000000..8806681 --- /dev/null +++ b/packages/js/acp-connector/src/permission.ts @@ -0,0 +1,172 @@ +/** + * Kernel-gated ACP permission broker. + * + * Tiering mechanism adapted (with attribution, Apache-2.0) from Rowboat's + * code-mode PermissionBroker (apps/x/packages/core/src/code-mode/acp/ + * permission-broker.ts): sticky per-session allows, a low-risk read tier, and + * option-family fallback mapping so a decision always lands on an option the + * agent actually offered. Reimplemented against HELM doctrine: + * + * - Every permission request is routed through a Kernel verdict. There is no + * local "yolo": the heaviest local convenience is auto-approve-reads, and + * even that only classifies the request into the low-risk tier — the + * kernel still issues the verdict. + * - Fail-closed default: DENY / ESCALATE / PENDING / transport error / + * timeout all resolve to a rejection, never an allow. + * - Sticky per-session allows are recorded as receipts: each sticky entry + * carries the kernel decisionId/receiptId that authorized it, so the + * "always allow" convenience remains auditable evidence. + */ + +import type { + PermissionAsk, + PermissionDecision, + PermissionOption, + PermissionOptionKind, + RequestPermissionRequest, + RequestPermissionResponse, +} from "./types.js"; +import { READ_TOOL_KINDS, type KernelEvaluator } from "./kernel-evaluator.js"; + +function toAsk(request: RequestPermissionRequest): PermissionAsk { + const tc = request.toolCall ?? {}; + const kind = typeof tc.kind === "string" ? tc.kind : undefined; + const title = typeof tc.title === "string" && tc.title ? tc.title : kind ?? "Tool call"; + return { + toolCallId: typeof tc.toolCallId === "string" ? tc.toolCallId : undefined, + title, + kind, + isRead: kind ? READ_TOOL_KINDS.has(kind) : false, + sessionId: request.sessionId, + }; +} + +/** + * Map a desired decision onto an option the agent actually offered, falling + * back within the same allow/reject family. Mechanism adapted from Rowboat's + * pickOption (Apache-2.0, see header note). + */ +export function pickPermissionOption( + options: PermissionOption[], + decision: PermissionDecision, +): PermissionOption | undefined { + const order: Record = { + allow_always: ["allow_always", "allow_once"], + allow_once: ["allow_once", "allow_always"], + reject: ["reject_once", "reject_always"], + }; + for (const kind of order[decision]) { + const found = options.find((o) => o.kind === kind); + if (found) return found; + } + return undefined; +} + +function selected(optionId: string): RequestPermissionResponse { + return { outcome: { outcome: "selected", optionId } }; +} + +function memoryKey(ask: PermissionAsk): string { + return ask.kind ? `kind:${ask.kind}` : `title:${ask.title}`; +} + +/** A sticky allow with its authorizing evidence — the recorded receipt. */ +export interface StickyAllowReceipt { + key: string; + decisionId?: string; + receiptId?: string; + tier: "low" | "standard"; + recordedAt: string; +} + +export interface GovernedPermissionBrokerOptions { + evaluator: KernelEvaluator; + policy: "ask" | "auto-approve-reads"; + agent: string; + cwd: string; + /** Notified of every resolved request (stream event + evidence trail). */ + onResolved?: (ask: PermissionAsk, decision: PermissionDecision, auto: boolean, receiptId?: string) => void; + /** Sink for sticky-allow receipts (e.g. append to a run evidence log). */ + onStickyAllow?: (receipt: StickyAllowReceipt) => void; +} + +export class GovernedPermissionBroker { + private readonly opts: GovernedPermissionBrokerOptions; + private readonly sticky = new Map(); + + constructor(opts: GovernedPermissionBrokerOptions) { + this.opts = opts; + } + + /** Sticky allows recorded so far (evidence surface for the run). */ + stickyAllowReceipts(): StickyAllowReceipt[] { + return [...this.sticky.values()]; + } + + async resolve(request: RequestPermissionRequest): Promise { + const ask = toAsk(request); + const key = memoryKey(ask); + + const finish = ( + decision: PermissionDecision, + auto: boolean, + receiptId?: string, + ): RequestPermissionResponse => { + this.opts.onResolved?.(ask, decision, auto, receiptId); + const opt = pickPermissionOption(request.options ?? [], decision); + // If the agent offered no matching option, fall back to its first one + // rather than deadlocking the turn. A reject with no offered option is + // answered as cancelled — fail-closed either way. + if (opt) return selected(opt.optionId); + const first = request.options?.[0]; + if (first && decision !== "reject") return selected(first.optionId); + return { outcome: { outcome: "cancelled" } }; + }; + + // 1. Sticky allow from earlier this session — itself the product of a + // kernel ALLOW, recorded with its receipt. + const prior = this.sticky.get(key); + if (prior) return finish("allow_always", true, prior.receiptId); + + // 2. Low-risk tier classification. auto-approve-reads never decides + // locally; it only marks the request low-risk for the kernel. + const tier: "low" | "standard" = + this.opts.policy === "auto-approve-reads" && ask.isRead ? "low" : "standard"; + + // 3. Kernel verdict. Fail-closed: any error or non-ALLOW verdict rejects. + let verdict; + try { + verdict = await this.opts.evaluator.evaluate({ + ask, + tier, + agent: this.opts.agent, + cwd: this.opts.cwd, + policy: this.opts.policy, + }); + } catch { + return finish("reject", true); + } + + if (verdict.verdict !== "ALLOW") { + return finish("reject", true, verdict.receiptId); + } + + // Kernel ALLOW. Sticky recording: only when the kernel marks the allow + // sticky, or for the low-risk tier under auto-approve-reads (the + // documented lightweight tier beneath the approval ceremony). + const sticky = verdict.stickyAllow === true || tier === "low"; + if (sticky) { + const receipt: StickyAllowReceipt = { + key, + decisionId: verdict.decisionId, + receiptId: verdict.receiptId, + tier, + recordedAt: new Date().toISOString(), + }; + this.sticky.set(key, receipt); + this.opts.onStickyAllow?.(receipt); + return finish("allow_always", true, verdict.receiptId); + } + return finish("allow_once", true, verdict.receiptId); + } +} diff --git a/packages/js/acp-connector/src/provisioning.test.ts b/packages/js/acp-connector/src/provisioning.test.ts new file mode 100644 index 0000000..d78db91 --- /dev/null +++ b/packages/js/acp-connector/src/provisioning.test.ts @@ -0,0 +1,236 @@ +/** Provisioning tests: sha512 integrity verification, signed-manifest + * enforcement (fail-closed), atomic install + ledger, receipted binary + * hashes, cache-hit re-verification, and tamper-triggered reprovisioning. */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as crypto from "node:crypto"; +import * as path from "node:path"; +import { spawnSync } from "node:child_process"; +import { + canonicalManifestBytes, + ensureEngine, + manifestDigestSha256, + sha512FileHex, + verifyIntegrity, + verifyManifestSignature, + type EngineManifest, +} from "./provisioning.js"; +import { cleanupTmpDir, makeTmpDir } from "./test-utils.js"; + +const BINARY_CONTENT = "#!/bin/sh\necho fake-engine-v1\n"; + +function platformKeyForTest(): string { + return `${process.platform}-${process.arch}`; +} + +interface Fixture { + dir: string; + tarballPath: string; + manifest: EngineManifest; + integrity: string; +} + +async function makeEngineFixture(): Promise { + const dir = await makeTmpDir("helm-acp-prov-"); + const pkgRoot = path.join(dir, "staging", "package"); + await fs.mkdir(path.join(pkgRoot, "bin"), { recursive: true }); + await fs.writeFile(path.join(pkgRoot, "bin", "engine"), BINARY_CONTENT, "utf8"); + const tarballPath = path.join(dir, "engine.tgz"); + const r = spawnSync("tar", ["-czf", tarballPath, "-C", path.join(dir, "staging"), "package"], { stdio: "pipe" }); + if (r.status !== 0) throw new Error(`test fixture tar failed: ${r.stderr?.toString()}`); + const data = await fs.readFile(tarballPath); + const integrity = `sha512-${crypto.createHash("sha512").update(data).digest("base64")}`; + const manifest: EngineManifest = { + "fake-agent": { + version: "1.0.0", + platforms: { + [platformKeyForTest()]: { + pkg: "fake-engine", + pkgVersion: "1.0.0", + tarball: `file://${tarballPath}`, + integrity, + executableRelPath: "bin/engine", + }, + }, + }, + }; + return { dir, tarballPath, manifest, integrity }; +} + +function fileFetch(): typeof globalThis.fetch { + return (async (input: unknown) => { + const url = String(input); + if (!url.startsWith("file://")) throw new Error(`test fetch only supports file:// — got ${url}`); + const data = await fs.readFile(url.slice("file://".length)); + return new Response(new Blob([data]).stream(), { + status: 200, + headers: { "content-length": String(data.length) }, + }); + }) as unknown as typeof globalThis.fetch; +} + +function makeKeyPair(): { publicKeyPem: string; privateKey: crypto.KeyObject } { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + return { publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(), privateKey }; +} + +test("happy path: verify → extract → atomic install → ledger + receipted binary hash", async (t) => { + if (process.platform === "win32") return t.skip("fixture uses posix tar"); + const fx = await makeEngineFixture(); + const enginesRoot = path.join(fx.dir, "engines"); + const receiptsDir = path.join(fx.dir, "receipts"); + try { + const result = await ensureEngine("fake-agent", { + manifest: fx.manifest, + enginesRoot, + receiptsDir, + fetch: fileFetch(), + }); + assert.equal(result.version, "1.0.0"); + assert.equal(await fs.readFile(result.executablePath, "utf8"), BINARY_CONTENT); + + // Executable bit set on posix. + const mode = (await fs.stat(result.executablePath)).mode & 0o111; + assert.notEqual(mode, 0, "installed binary must be executable"); + + // Ledger written with the receipted hash. + const ledger = JSON.parse( + await fs.readFile(path.join(enginesRoot, "fake-agent", ".meta", "fake-agent-1.0.0.json"), "utf8"), + ); + const expectedHash = await sha512FileHex(result.executablePath); + assert.equal(ledger.binarySha512, expectedHash); + assert.equal(ledger.integrity, fx.integrity); + assert.equal(ledger.manifestDigestSha256, manifestDigestSha256(fx.manifest)); + + // Receipt persisted, carrying the hash of the exact bytes that will run. + assert.equal(result.receipt.binarySha512, expectedHash); + const receiptFiles = await fs.readdir(receiptsDir); + assert.equal(receiptFiles.length, 1); + const persisted = JSON.parse(await fs.readFile(path.join(receiptsDir, receiptFiles[0]), "utf8")); + assert.equal(persisted.receiptId, result.receipt.receiptId); + assert.equal(persisted.binarySha512, expectedHash); + } finally { + await cleanupTmpDir(fx.dir); + } +}); + +test("integrity mismatch fails closed — corrupt/tampered download never installs", async (t) => { + if (process.platform === "win32") return t.skip("fixture uses posix tar"); + const fx = await makeEngineFixture(); + fx.manifest["fake-agent"].platforms[platformKeyForTest()].integrity = + `sha512-${crypto.createHash("sha512").update("not-the-tarball").digest("base64")}`; + try { + await assert.rejects( + ensureEngine("fake-agent", { manifest: fx.manifest, enginesRoot: path.join(fx.dir, "engines"), fetch: fileFetch() }), + /integrity check failed/, + ); + // Nothing installed. + await assert.rejects(fs.stat(path.join(fx.dir, "engines", "fake-agent", "1.0.0"))); + } finally { + await cleanupTmpDir(fx.dir); + } +}); + +test("signed manifest: valid signature installs; missing/invalid fails closed before download", async (t) => { + if (process.platform === "win32") return t.skip("fixture uses posix tar"); + const fx = await makeEngineFixture(); + const { publicKeyPem, privateKey } = makeKeyPair(); + const signature = crypto.sign(null, canonicalManifestBytes(fx.manifest), privateKey).toString("base64"); + try { + // Missing signature → refuse before touching the network. + let fetched = false; + const countingFetch: typeof globalThis.fetch = (async (...args: unknown[]) => { + fetched = true; + return (fileFetch() as unknown as (...a: unknown[]) => unknown)(...args); + }) as unknown as typeof globalThis.fetch; + await assert.rejects( + ensureEngine("fake-agent", { + manifest: fx.manifest, + enginesRoot: path.join(fx.dir, "e1"), + trustedPublicKeys: [publicKeyPem], + fetch: countingFetch, + }), + /signature is required/, + ); + assert.equal(fetched, false, "download must not start without a valid signature"); + + // Wrong key → refuse. + const other = makeKeyPair(); + await assert.rejects( + ensureEngine("fake-agent", { + manifest: fx.manifest, + manifestSignature: signature, + enginesRoot: path.join(fx.dir, "e2"), + trustedPublicKeys: [other.publicKeyPem], + fetch: fileFetch(), + }), + /did not verify/, + ); + + // Valid signature → install proceeds. + const ok = await ensureEngine("fake-agent", { + manifest: fx.manifest, + manifestSignature: signature, + enginesRoot: path.join(fx.dir, "e3"), + trustedPublicKeys: [publicKeyPem, other.publicKeyPem], + fetch: fileFetch(), + }); + assert.equal(ok.version, "1.0.0"); + } finally { + await cleanupTmpDir(fx.dir); + } +}); + +test("verifyManifestSignature: canonicalization makes key order irrelevant", () => { + const { publicKeyPem, privateKey } = makeKeyPair(); + const a = { b: 1, a: { z: 2, y: 3 } }; + const sig = crypto.sign(null, canonicalManifestBytes(a), privateKey).toString("base64"); + // Same content, different key order → same canonical bytes → verifies. + verifyManifestSignature({ a: { y: 3, z: 2 }, b: 1 }, sig, [publicKeyPem]); + assert.throws(() => verifyManifestSignature({ a: { y: 3, z: 4 }, b: 1 }, sig, [publicKeyPem]), /did not verify/); +}); + +test("cache hit re-verifies the ledger hash; tampered cache reprovisions", async (t) => { + if (process.platform === "win32") return t.skip("fixture uses posix tar"); + const fx = await makeEngineFixture(); + const enginesRoot = path.join(fx.dir, "engines"); + let fetches = 0; + const countingFetch = (async (input: unknown) => { + fetches++; + const url = String(input); + const data = await fs.readFile(url.slice("file://".length)); + return new Response(new Blob([data]).stream(), { status: 200 }); + }) as unknown as typeof globalThis.fetch; + try { + const first = await ensureEngine("fake-agent", { manifest: fx.manifest, enginesRoot, fetch: countingFetch }); + assert.equal(fetches, 1); + + // Cache hit: no second download, but a fresh receipt is still emitted. + const second = await ensureEngine("fake-agent", { manifest: fx.manifest, enginesRoot, fetch: countingFetch }); + assert.equal(fetches, 1); + assert.equal(second.receipt.binarySha512, first.receipt.binarySha512); + assert.notEqual(second.receipt.receiptId, first.receipt.receiptId); + + // Tamper with the installed binary → the next ensure reprovisions. + await fs.writeFile(first.executablePath, "evil-binary", "utf8"); + const third = await ensureEngine("fake-agent", { manifest: fx.manifest, enginesRoot, fetch: countingFetch }); + assert.equal(fetches, 2, "tampered cache must trigger a fresh verified download"); + assert.equal(await fs.readFile(third.executablePath, "utf8"), BINARY_CONTENT); + } finally { + await cleanupTmpDir(fx.dir); + } +}); + +test("verifyIntegrity rejects malformed and wrong-algorithm strings", async () => { + const dir = await makeTmpDir("helm-acp-prov-"); + try { + const f = path.join(dir, "x.bin"); + await fs.writeFile(f, "data", "utf8"); + await assert.rejects(verifyIntegrity(f, "garbage"), /malformed/); + await assert.rejects(verifyIntegrity(f, "md5-deadbeef"), /unsupported integrity algorithm/); + } finally { + await cleanupTmpDir(dir); + } +}); diff --git a/packages/js/acp-connector/src/provisioning.ts b/packages/js/acp-connector/src/provisioning.ts new file mode 100644 index 0000000..7607b3a --- /dev/null +++ b/packages/js/acp-connector/src/provisioning.ts @@ -0,0 +1,428 @@ +/** + * Managed engine provisioning client: lockfile-pinned versions, sha512 + * verification, atomic install, ledger — plus the HELM outperform Rowboat + * does not have: an Ed25519-signed manifest and provisioned-binary hashes + * receipted into evidence records. + * + * Install-pipeline mechanisms adapted (with attribution, Apache-2.0) from + * Rowboat's engine-provisioner.ts (apps/x/packages/core/src/code-mode/acp/): + * download → SRI verify → temp-dir extract → atomic rename → .meta ledger → + * prune superseded versions; fast-path cache check; per-call unique temp dirs + * so concurrent callers are safe. Reimplemented with two governance additions: + * + * 1. SIGNED MANIFEST. The manifest maps agent → version → per-platform + * tarball + sha512 integrity + executable path. When trusted release + * public keys are configured, provisioning REFUSES to proceed without a + * valid Ed25519 signature over the canonical manifest bytes (fail-closed; + * Rowboat verifies tarball hashes but nothing authenticates the manifest + * itself — their R4 supply-chain risk). + * 2. RECEIPTED INSTALLS. A successful install emits a ProvisioningReceipt + * (sha512 of the installed binary, manifest digest, integrity string, + * ledger path) so the exact bytes being executed downstream are + * receipted evidence. + * + * CONTRACT NOTE (helm-desktop / platform-actions): the manifest is expected + * to be generated in platform-actions CI from lockfile pins (same role as + * Rowboat's gen-engine-manifest.mjs), signed with the HELM release key, and + * shipped beside helm-desktop. helm-desktop should consume THIS package's + * ensureEngine()/verifyManifestSignature() rather than reimplementing the + * pipeline. The receipt object is the handoff point for EvidencePack + * assembly on the desktop side. + */ + +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +export interface EnginePlatformEntry { + /** npm package name the tarball was pinned from. */ + pkg: string; + pkgVersion: string; + /** Tarball URL (lockfile-pinned registry URL). */ + tarball: string; + /** npm Subresource Integrity string, "sha512-". */ + integrity: string; + /** Executable path relative to the extracted package root. */ + executableRelPath: string; +} + +export interface EngineManifestEntry { + version: string; + platforms: Record; +} + +export type EngineManifest = Record; + +export interface EngineProgress { + phase: "check" | "verify-manifest" | "download" | "verify" | "extract" | "receipt" | "done"; + receivedBytes?: number; + totalBytes?: number; +} + +export interface ProvisioningReceipt { + receiptId: string; + agent: string; + version: string; + platform: string; + tarball: string; + tarballIntegrity: string; + /** sha512 (hex) of the installed executable — the bytes that will run. */ + binarySha512: string; + /** sha256 (hex) of the canonical manifest bytes used for this install. */ + manifestDigestSha256: string; + executablePath: string; + ledgerPath: string; + installedAt: string; +} + +export interface EnsureEngineOptions { + manifest: EngineManifest; + /** Base64 Ed25519 signature over canonicalManifestBytes(manifest). */ + manifestSignature?: string; + /** PEM Ed25519 public keys trusted to sign engine manifests. When + * non-empty, a missing/invalid signature fails closed. */ + trustedPublicKeys?: string[]; + enginesRoot?: string; + receiptsDir?: string; + onProgress?: (p: EngineProgress) => void; + signal?: AbortSignal; + /** Injectable for tests; defaults to global fetch. */ + fetch?: typeof globalThis.fetch; +} + +export interface ProvisionedEngine { + executablePath: string; + version: string; + receipt: ProvisioningReceipt; +} + +export const DEFAULT_ENGINES_ROOT = path.join(os.homedir(), ".helm", "engines"); + +/** Deterministic JSON (recursively sorted keys) — the signing surface. */ +export function canonicalManifestBytes(manifest: unknown): Buffer { + const canonical = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === "object") { + const out: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + out[key] = canonical((value as Record)[key]); + } + return out; + } + return value; + }; + return Buffer.from(JSON.stringify(canonical(manifest)), "utf8"); +} + +export function manifestDigestSha256(manifest: unknown): string { + return crypto.createHash("sha256").update(canonicalManifestBytes(manifest)).digest("hex"); +} + +/** + * Verify the manifest's Ed25519 signature against the trusted key set. + * Fail-closed: throws unless at least one trusted key verifies. + */ +export function verifyManifestSignature( + manifest: unknown, + signatureBase64: string | undefined, + trustedPublicKeys: string[], +): void { + if (trustedPublicKeys.length === 0) return; // unsigned mode (dev only) + if (!signatureBase64) { + throw new Error("HELM engine manifest signature is required but missing (fail-closed)"); + } + const payload = canonicalManifestBytes(manifest); + const signature = Buffer.from(signatureBase64, "base64"); + for (const pem of trustedPublicKeys) { + try { + const key = crypto.createPublicKey(pem); + if (crypto.verify(null, payload, key, signature)) return; + } catch { + // try the next trusted key + } + } + throw new Error("HELM engine manifest signature did not verify against any trusted key (fail-closed)"); +} + +/** Map this process's platform/arch (+ libc on linux) to a manifest key. */ +export function platformKey(entry: EngineManifestEntry): string | null { + const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null; + if (!arch) return null; + const candidates: string[] = []; + if (process.platform === "darwin") { + candidates.push(`darwin-${arch}`); + } else if (process.platform === "win32") { + candidates.push(`win32-${arch}`); + } else if (process.platform === "linux") { + if (isMuslLibc()) candidates.push(`linux-${arch}-musl`); + candidates.push(`linux-${arch}`); + } + return candidates.find((c) => c in entry.platforms) ?? null; +} + +// glibc builds expose glibcVersionRuntime in the process report header; musl +// (Alpine) does not. Heuristic adapted from Rowboat's provisioner. +function isMuslLibc(): boolean { + try { + const report = (process as unknown as { report?: { getReport?: () => unknown } }).report?.getReport?.(); + const header = (report as { header?: Record } | undefined)?.header; + return !(header && "glibcVersionRuntime" in header); + } catch { + return false; + } +} + +function executablePath(root: string, plat: EnginePlatformEntry): string | null { + const p = path.join(root, plat.executableRelPath); + return fs.existsSync(p) ? p : null; +} + +/** Verify the tarball against the npm SRI string ("sha512-"). */ +export async function verifyIntegrity(file: string, integrity: string): Promise { + const dash = integrity.indexOf("-"); + if (dash <= 0) throw new Error("HELM provisioning: malformed integrity string"); + const algo = integrity.slice(0, dash); + if (algo !== "sha512" && algo !== "sha256" && algo !== "sha384") { + throw new Error(`HELM provisioning: unsupported integrity algorithm ${algo}`); + } + const expected = integrity.slice(dash + 1); + const actual = crypto.createHash(algo).update(await fsp.readFile(file)).digest("base64"); + if (actual !== expected) { + throw new Error(`HELM provisioning: integrity check failed (${algo}) — download may be corrupt or tampered`); + } +} + +export async function sha512FileHex(file: string): Promise { + return crypto.createHash("sha512").update(await fsp.readFile(file)).digest("hex"); +} + +// Extract an npm tarball, stripping its leading `package/` component. Uses the +// system tar (bsdtar on macOS/Windows 10+, GNU tar on Linux) — all support +// -xzf and --strip-components. Windows pinning adapted from Rowboat: PATH tar +// may be a GNU tar that misreads drive-letter paths; pin System32 bsdtar. +function extractTarball(tarPath: string, destDir: string): void { + let tarCmd = "tar"; + let tarArgs = ["-xzf", tarPath, "-C", destDir, "--strip-components=1"]; + let spawnOpts: Parameters[2] = { stdio: "pipe" }; + if (process.platform === "win32") { + const sysTar = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe"); + if (fs.existsSync(sysTar)) { + tarCmd = sysTar; + } else { + tarArgs = ["-xzf", path.basename(tarPath), "-C", destDir, "--strip-components=1"]; + spawnOpts = { stdio: "pipe", cwd: path.dirname(tarPath) }; + } + } + const r = spawnSync(tarCmd, tarArgs, spawnOpts); + if (r.status !== 0) { + const err = r.stderr?.toString().trim() || r.error?.message || `tar exited ${r.status}`; + throw new Error(`HELM provisioning: failed to extract engine — ${err}`); + } +} + +async function downloadTo(url: string, dest: string, opts: EnsureEngineOptions): Promise { + const fetchImpl = opts.fetch ?? globalThis.fetch; + if (!fetchImpl) throw new Error("No fetch implementation available for engine download"); + opts.onProgress?.({ phase: "download", receivedBytes: 0 }); + const res = await fetchImpl(url, { signal: opts.signal }); + if (!res.ok || !res.body) { + throw new Error(`HELM provisioning: engine download failed (HTTP ${res.status}) — ${url}`); + } + const total = Number(res.headers.get("content-length")) || undefined; + let received = 0; + const body = Readable.fromWeb(res.body as Parameters[0]); + body.on("data", (chunk: Buffer) => { + received += chunk.length; + opts.onProgress?.({ phase: "download", receivedBytes: received, totalBytes: total }); + }); + await pipeline(body, fs.createWriteStream(dest)); +} + +// Remove every provisioned version except keepVersion (+ stale .meta entries). +// Best-effort — cleanup must never fail a good install. +function pruneOldVersions(enginesRoot: string, agent: string, keepVersion: string): void { + const agentRoot = path.join(enginesRoot, agent); + try { + for (const name of fs.readdirSync(agentRoot)) { + if (name === keepVersion || name === ".meta" || name.startsWith(".tmp-")) continue; + const full = path.join(agentRoot, name); + try { + if (fs.statSync(full).isDirectory()) fs.rmSync(full, { recursive: true, force: true }); + } catch { + /* ignore a single stubborn entry */ + } + } + const metaDir = path.join(agentRoot, ".meta"); + if (fs.existsSync(metaDir)) { + for (const f of fs.readdirSync(metaDir)) { + if (f !== `${agent}-${keepVersion}.json`) { + try { + fs.rmSync(path.join(metaDir, f), { force: true }); + } catch { + /* ignore */ + } + } + } + } + } catch { + /* agentRoot unreadable — nothing to prune */ + } +} + +/** + * Ensure the pinned engine for `agent` is provisioned locally, downloading it + * on first use. Idempotent, cached, concurrency-safe (unique temp dirs; the + * final rename is idempotent). Emits a ProvisioningReceipt for every install + * AND every cache hit — the running engine is always receipted. + */ +export async function ensureEngine(agent: string, opts: EnsureEngineOptions): Promise { + const entry = opts.manifest[agent]; + if (!entry) throw new Error(`HELM provisioning: no manifest entry for agent ${JSON.stringify(agent)}`); + const enginesRoot = opts.enginesRoot ?? DEFAULT_ENGINES_ROOT; + + opts.onProgress?.({ phase: "verify-manifest" }); + verifyManifestSignature(opts.manifest, opts.manifestSignature, opts.trustedPublicKeys ?? []); + + const version = entry.version; + const key = platformKey(entry); + if (!key) { + throw new Error(`HELM provisioning: no ${agent} engine is available for ${process.platform}/${process.arch}`); + } + const plat = entry.platforms[key]; + + const agentRoot = path.join(enginesRoot, agent); + const versionDir = path.join(agentRoot, version); + const metaDir = path.join(agentRoot, ".meta"); + const metaPath = path.join(metaDir, `${agent}-${version}.json`); + const manifestDigest = manifestDigestSha256(opts.manifest); + + const buildReceipt = async (exe: string): Promise => ({ + receiptId: `prov-${agent}-${version}-${crypto.randomBytes(6).toString("hex")}`, + agent, + version, + platform: key, + tarball: plat.tarball, + tarballIntegrity: plat.integrity, + binarySha512: await sha512FileHex(exe), + manifestDigestSha256: manifestDigest, + executablePath: exe, + ledgerPath: metaPath, + installedAt: new Date().toISOString(), + }); + + const persistReceipt = async (receipt: ProvisioningReceipt): Promise => { + if (!opts.receiptsDir) return; + await fsp.mkdir(opts.receiptsDir, { recursive: true }); + await fsp.writeFile( + path.join(opts.receiptsDir, `${receipt.receiptId}.json`), + JSON.stringify(receipt, null, 2), + "utf8", + ); + }; + + opts.onProgress?.({ phase: "check" }); + // Fast path: already provisioned and intact — re-verify the binary hash + // against the ledger so a tampered cache is not silently reused. + const existing = executablePath(versionDir, plat); + if (existing && fs.existsSync(metaPath)) { + try { + const ledger = JSON.parse(fs.readFileSync(metaPath, "utf8")) as { binarySha512?: string }; + if (ledger.binarySha512 && ledger.binarySha512 !== (await sha512FileHex(existing))) { + throw new Error("cached engine binary hash mismatch — reprovisioning"); + } + const receipt = await buildReceipt(existing); + await persistReceipt(receipt); + opts.onProgress?.({ phase: "done" }); + return { executablePath: existing, version, receipt }; + } catch (err) { + if (err instanceof Error && err.message.includes("hash mismatch")) { + fs.rmSync(versionDir, { recursive: true, force: true }); + fs.rmSync(metaPath, { force: true }); + } else { + throw err; + } + } + } + + fs.mkdirSync(agentRoot, { recursive: true }); + const tmpRoot = fs.mkdtempSync(path.join(agentRoot, `.tmp-${version}-`)); + try { + const tarPath = path.join(tmpRoot, "engine.tgz"); + await downloadTo(plat.tarball, tarPath, opts); + + opts.onProgress?.({ phase: "verify" }); + await verifyIntegrity(tarPath, plat.integrity); + + opts.onProgress?.({ phase: "extract" }); + const extractDir = path.join(tmpRoot, "pkg"); + fs.mkdirSync(extractDir); + extractTarball(tarPath, extractDir); + + const exe = executablePath(extractDir, plat); + if (!exe) { + throw new Error(`HELM provisioning: ${agent} engine binary not found at ${plat.executableRelPath} in the package`); + } + if (process.platform !== "win32") fs.chmodSync(exe, 0o755); + + if (fs.existsSync(versionDir)) fs.rmSync(versionDir, { recursive: true, force: true }); + fs.renameSync(extractDir, versionDir); + + const finalExe = executablePath(versionDir, plat); + if (!finalExe) { + throw new Error(`HELM provisioning: ${agent} engine binary missing after install`); + } + + opts.onProgress?.({ phase: "receipt" }); + const receipt = await buildReceipt(finalExe); + fs.mkdirSync(metaDir, { recursive: true }); + fs.writeFileSync( + metaPath, + JSON.stringify( + { + version, + platform: key, + integrity: plat.integrity, + executableRelPath: plat.executableRelPath, + binarySha512: receipt.binarySha512, + manifestDigestSha256: manifestDigest, + installedAt: receipt.installedAt, + }, + null, + 2, + ), + ); + await persistReceipt(receipt); + + pruneOldVersions(enginesRoot, agent, version); + opts.onProgress?.({ phase: "done" }); + return { executablePath: finalExe, version, receipt }; + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +} + +/** + * Return the provisioned engine's executable path, or throw. The runtime path + * deliberately does NOT download: provisioning must happen up front so the + * user never eats a surprise multi-hundred-MB download mid-conversation. + */ +export function getProvisionedEnginePath( + agent: string, + manifest: EngineManifest, + enginesRoot: string = DEFAULT_ENGINES_ROOT, +): string { + const entry = manifest[agent]; + if (!entry) throw new Error(`HELM provisioning: no manifest entry for agent ${JSON.stringify(agent)}`); + const key = platformKey(entry); + const plat = key ? entry.platforms[key] : undefined; + const exe = plat ? executablePath(path.join(enginesRoot, agent, entry.version), plat) : null; + if (!exe) { + throw new Error(`HELM: the ${agent} engine is not provisioned yet — provision it before starting a session`); + } + return exe; +} diff --git a/packages/js/acp-connector/src/session-store.ts b/packages/js/acp-connector/src/session-store.ts new file mode 100644 index 0000000..0674a0b --- /dev/null +++ b/packages/js/acp-connector/src/session-store.ts @@ -0,0 +1,59 @@ +/** + * Per-run ACP session store: persists the engine session id so a cold + * restart resumes via session/load. Mechanism adapted (with attribution, + * Apache-2.0) from Rowboat's code-mode acp/session-store.ts. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CodingAgent } from "./types.js"; + +export interface StoredSession { + runId: string; + agent: CodingAgent; + cwd: string; + sessionId: string; +} + +export class SessionStore { + private readonly dir: string; + + constructor(dir: string) { + this.dir = dir; + } + + private fileFor(runId: string): string { + const safe = runId.replace(/[^a-zA-Z0-9._-]/g, "_"); + return path.join(this.dir, `${safe}.json`); + } + + async read(runId: string): Promise { + try { + const raw = await fs.readFile(this.fileFor(runId), "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.runId === "string" && + typeof parsed.agent === "string" && + typeof parsed.cwd === "string" && + typeof parsed.sessionId === "string" + ) { + return parsed as StoredSession; + } + return null; + } catch { + return null; + } + } + + async write(session: StoredSession): Promise { + await fs.mkdir(this.dir, { recursive: true }); + const target = this.fileFor(session.runId); + const tmp = `${target}.tmp-${process.pid}`; + await fs.writeFile(tmp, JSON.stringify(session, null, 2), "utf8"); + await fs.rename(tmp, target); + } + + async clear(runId: string): Promise { + await fs.rm(this.fileFor(runId), { force: true }); + } +} diff --git a/packages/js/acp-connector/src/test-utils.ts b/packages/js/acp-connector/src/test-utils.ts new file mode 100644 index 0000000..d45ae04 --- /dev/null +++ b/packages/js/acp-connector/src/test-utils.ts @@ -0,0 +1,46 @@ +/** Shared helpers for acp-connector tests (not exported from the package). */ + +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterLaunchSpec } from "./client.js"; +import type { KernelEvaluator, KernelVerdict } from "./kernel-evaluator.js"; +import { FsGuard } from "./fs-guard.js"; + +export const FAKE_AGENT_FIXTURE = fileURLToPath(new URL("../fixtures/fake-acp-agent.mjs", import.meta.url)); + +export function fakeLaunchSpec(behavior: Record): AdapterLaunchSpec { + return { + command: process.execPath, + args: [FAKE_AGENT_FIXTURE], + env: { ...process.env, FAKE_AGENT_BEHAVIOR: JSON.stringify(behavior) }, + }; +} + +export async function makeTmpDir(prefix = "helm-acp-test-"): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +export async function cleanupTmpDir(dir: string): Promise { + await fs.rm(dir, { recursive: true, force: true }); +} + +/** Kernel evaluator stub with programmable verdicts and call counting. */ +export class FakeKernelEvaluator implements KernelEvaluator { + calls: Array<{ tier: string; kind?: string }> = []; + verdicts: KernelVerdict[] = []; + defaultVerdict: KernelVerdict = { verdict: "ALLOW", receiptId: "rcpt-fake", decisionId: "dec-fake" }; + throwError: Error | null = null; + + async evaluate(request: { tier: "low" | "standard"; ask: { kind?: string } }): Promise { + this.calls.push({ tier: request.tier, kind: request.ask.kind }); + if (this.throwError) throw this.throwError; + return this.verdicts.length > 0 ? this.verdicts.shift()! : this.defaultVerdict; + } +} + +/** FsGuard rooted at a single read+write tmp dir. */ +export function guardFor(root: string, extra: Array<{ path: string; read: boolean; write: boolean }> = []): FsGuard { + return new FsGuard({ roots: [{ path: root, read: true, write: true }, ...extra] }); +} diff --git a/packages/js/acp-connector/src/types.ts b/packages/js/acp-connector/src/types.ts new file mode 100644 index 0000000..0009d6d --- /dev/null +++ b/packages/js/acp-connector/src/types.ts @@ -0,0 +1,137 @@ +/** + * ACP (Agent Client Protocol) wire types — the subset this connector speaks. + * + * ACP is the Zed-originated JSON-RPC protocol (ndJSON over stdio) used to + * drive vendor coding agents. Wire shape follows the public ACP schema + * (https://agentclientprotocol.com). The connector design is adapted from + * Rowboat's Apache-2.0 code-mode ACP client (apps/x/packages/core/src/ + * code-mode/acp/) — mechanisms reimplemented, no Rowboat code copied. + */ + +export const ACP_PROTOCOL_VERSION = 1; + +/** Supported embedded coding engines. */ +export type CodingAgent = "claude" | "codex"; + +/** Outcome kinds an agent may offer on a permission request. */ +export type PermissionOptionKind = + | "allow_once" + | "allow_always" + | "reject_once" + | "reject_always" + | string; + +export interface PermissionOption { + optionId: string; + name?: string; + kind?: PermissionOptionKind; +} + +export interface ToolCallUpdate { + toolCallId?: string; + title?: string; + kind?: string; + status?: string; + [key: string]: unknown; +} + +export interface RequestPermissionRequest { + sessionId: string; + toolCall: ToolCallUpdate; + options: PermissionOption[]; +} + +export type RequestPermissionOutcome = + | { outcome: "selected"; optionId: string } + | { outcome: "cancelled" }; + +export interface RequestPermissionResponse { + outcome: RequestPermissionOutcome; +} + +export interface ReadTextFileRequest { + sessionId: string; + path: string; + line?: number | null; + limit?: number | null; +} + +export interface ReadTextFileResponse { + content: string; +} + +export interface WriteTextFileRequest { + sessionId: string; + path: string; + content: string; +} + +export type WriteTextFileResponse = Record; + +/** Session update notification payload (agent → client). Kept permissive. */ +export interface SessionUpdate { + sessionUpdate: string; + [key: string]: unknown; +} + +export interface SessionNotification { + sessionId: string; + update: SessionUpdate; +} + +export interface PromptResponse { + stopReason: string; + [key: string]: unknown; +} + +export interface InitializeResponse { + protocolVersion: number; + agentCapabilities?: { + loadSession?: boolean; + [key: string]: unknown; + }; + agentInfo?: { name?: string; version?: string } | null; + authMethods?: unknown[]; + [key: string]: unknown; +} + +export interface NewSessionResponse { + sessionId: string; + configOptions?: unknown; + models?: unknown; + modes?: unknown; + [key: string]: unknown; +} + +/** Normalized event stream emitted by the connector (sessionUpdate mapped). */ +export type AcpRunEvent = + | { type: "message"; role: "user" | "agent"; text: string } + | { type: "thought" } + | { type: "tool_call"; id?: string; title?: string; kind?: string; status?: string } + | { type: "tool_call_update"; id?: string; status?: string; diffs: string[] } + | { type: "plan"; entries: Array<{ content: string; status?: string; priority?: string }> } + | { type: "usage"; used?: number; size?: number } + | { type: "permission"; ask: PermissionAsk; decision: PermissionDecision; auto: boolean; receiptId?: string } + | { type: "other"; sessionUpdate: string }; + +/** A normalized permission question derived from a requestPermission call. */ +export interface PermissionAsk { + toolCallId?: string; + title: string; + kind?: string; + isRead: boolean; + sessionId: string; +} + +/** Internal decision vocabulary before mapping onto offered options. */ +export type PermissionDecision = "allow_always" | "allow_once" | "reject"; + +/** Interactive permission policy for a session. Deliberately NO "yolo": + * fail-closed is the doctrine; every allow is either a kernel ALLOW or a + * recorded sticky allow beneath one. */ +export type ApprovalPolicy = "ask" | "auto-approve-reads"; + +export interface RunPromptResult { + stopReason: string; + sessionId: string; +} diff --git a/packages/js/acp-connector/tsconfig.json b/packages/js/acp-connector/tsconfig.json new file mode 100644 index 0000000..0765abb --- /dev/null +++ b/packages/js/acp-connector/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "lib": ["ES2022", "DOM"], + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}