import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { SOURCES } from "../src/bundle.mjs"; import { scratch } from "./helpers.mjs"; function setup(t, harness = "claude-code") { const dir = scratch(t); const workspace = join(dir, "ws"); mkdirSync(workspace); const policy = join(dir, "policy.json"); writeFileSync(policy, JSON.stringify({ harness, workspace, tools: ["read", "bash"], typed: ["send_message"] })); return { dir, workspace, policy }; } // Runs the gate the way the hook does, directly or through a command string. const gate = (policy, event) => spawnSync(process.execPath, [SOURCES.claudeGate, policy], { input: JSON.stringify(event), encoding: "utf8" }); const hook = (command, event) => spawnSync("sh", ["-c", command], { input: JSON.stringify(event), encoding: "utf8" }); const event = (tool_name, tool_input = {}) => ({ hook_event_name: "PreToolUse", tool_name, tool_input }); test("allow exits 0, a deny exits 2 with the reason on stderr", (t) => { const { workspace, policy } = setup(t); assert.equal(gate(policy, event("Read", { file_path: join(workspace, "a") })).status, 0); assert.equal(gate(policy, event("mcp__mosaic__send_message", { to: "pm", body: "x" })).status, 0); const denied = gate(policy, event("Write", { file_path: join(workspace, "a"), content: "" })); assert.equal(denied.status, 2); assert.match(denied.stderr, /^mosaic gate: Write isn't in this session's policy/); const outside = gate(policy, event("Read", { file_path: "/etc/passwd" })); assert.equal(outside.status, 2); assert.match(outside.stderr, /outside the workspace/); }); test("a missing or wrong policy, or a bad event, exits 2", (t) => { const { dir, policy } = setup(t); assert.equal(gate(join(dir, "missing.json"), event("Read")).status, 2); const pi = setup(t, "pi").policy; const r = gate(pi, event("Read")); assert.equal(r.status, 2); assert.match(r.stderr, /not a claude-code policy/); const bad = spawnSync(process.execPath, [SOURCES.claudeGate, policy], { input: "{", encoding: "utf8" }); assert.equal(bad.status, 2); }); test("the bundle's wrapped command: a missing gate or node still blocks", (t) => { const { workspace, policy } = setup(t); const command = (node, script) => `timeout -k 2 10 '${node}' '${script}' '${policy}' || exit 2`; const allow = event("Read", { file_path: join(workspace, "a") }); assert.equal(hook(command(process.execPath, SOURCES.claudeGate), allow).status, 0); assert.equal(hook(command(process.execPath, join(workspace, "no-gate.mjs")), allow).status, 2); assert.equal(hook(command(join(workspace, "no-node"), SOURCES.claudeGate), allow).status, 2); // A gate that hangs is killed by timeout and blocks. const slow = join(workspace, "slow.mjs"); writeFileSync(slow, "setTimeout(() => {}, 60000);\n"); const started = Date.now(); const r = hook(`timeout -k 1 1 '${process.execPath}' '${slow}' '${policy}' || exit 2`, allow); assert.equal(r.status, 2); assert.ok(Date.now() - started < 5000); // A policy removed after the bundle was written blocks too. rmSync(policy); assert.equal(hook(command(process.execPath, SOURCES.claudeGate), allow).status, 2); });