Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions packages/js/acp-connector/README.md
Original file line number Diff line number Diff line change
@@ -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.
168 changes: 168 additions & 0 deletions packages/js/acp-connector/fixtures/fake-acp-agent.mjs
Original file line number Diff line number Diff line change
@@ -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");
48 changes: 48 additions & 0 deletions packages/js/acp-connector/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading