/** * Contract tests for guardrails.js * * Drives the plugin with opencode's real hook shapes: (input, output) where * input has {tool, sessionID, callID} and output has {args}. Builds a client * whose session.get returns the real key set, writes a real .omo/guardrails.json * (based on guardrails.example.json) and real .omo/boulder.json into a temp dir. * * Acceptance probe — every rule at default, real hook shape. * Per-rule — one positive (blocks) and one negative (allows) case each. * Only the tick gate test may use the real scripts/verify_commit.py. */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { copyFileSync, writeFileSync, readFileSync, existsSync, mkdirSync, } from "node:fs"; import { join } from "node:path"; import { mkdtempSync } from "node:fs"; import os from "node:os"; import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { Guardrails } from "./guardrails.js"; const execFileAsync = promisify(execFile); const __dirname = fileURLToPath(new URL(".", import.meta.url)); // --------------------------------------------------------------------------- // Constants — real guardrails.example.json content (Design C defaults) // --------------------------------------------------------------------------- /** Mirrors the default guardrails.example.json shipped in deploy/. */ const EXAMPLE_CONFIG = { rules: { task_needs_agent: "block", task_banned_agent: "block", task_worktree_line: "block", write_outside_worktree: "block", bash_main_checkout: "block", bash_banned: "block", bash_protected_port: "block", plan_tick_gate: "block", }, log: ".omo/guardrails.log", protected_ports: [8080], tick_gate: { cmd: [ "python3", "{directory}/scripts/verify_commit.py", "--repo", "{worktree}", "--require-clean", "HEAD", ], timeout_s: 900, }, }; const SCRIPT_DIR = join(__dirname, "..", "..", "scripts"); /** Full path to python3 — required by tick_gate because guardrails checks * existsSync on resolvedArgs[0], which must be a file path, not a PATH command. */ const PYTHON3 = execFileSync("which", ["python3"], { encoding: "utf-8" }).trim(); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function newDir() { return mkdtempSync(join(os.tmpdir(), "guardrails-contract-")); } function writeConfig(dir, configObj) { const cfgDir = join(dir, ".omo"); mkdirSync(cfgDir, { recursive: true }); writeFileSync(join(cfgDir, "guardrails.json"), JSON.stringify(configObj)); return cfgDir; } function writeBoulder(dir, boulderObj) { const boulderDir = join(dir, ".omo"); if (!existsSync(boulderDir)) { mkdirSync(boulderDir, { recursive: true }); } writeFileSync(join(boulderDir, "boulder.json"), JSON.stringify(boulderObj)); } /** * Build a client whose session.get returns the real key set. * The guardrails plugin reads result.data.parentID for scope walk. */ function realClient(extraData) { return { session: { get: async ({ path }) => ({ data: { agent: "Sisyphus-ultraworker", cost: 0.12, directory: "/home/test/project", id: path?.id ?? "ses_contract", model: "gpt-5.6", path: "/home/test/project/main", projectID: "6krrt", slug: "6krrt", summary: "Refactor router dispatch", time: "2026-10-04T12:00:00Z", title: "R6 — contract test", tokens: 4821, version: "2.0.0", ...extraData, }, }), }, }; } /** * Drive the hook with opencode's real shape: * await hooks["tool.execute.before"]({tool, sessionID, callID}, {args}) * * All tests use this function — never build input.args. * The {args} object is always on the OUTPUT parameter. */ async function hookDrive(hooks, tool, sessionID, args) { return hooks["tool.execute.before"]( { tool, sessionID, callID: "call_contract" }, { args }, ); } // --------------------------------------------------------------------------- // Loader contract // --------------------------------------------------------------------------- describe("loader contract", () => { it("every named export from guardrails.js is a function", async () => { const mod = await import("./guardrails.js"); for (const [name, value] of Object.entries(mod)) { assert.equal( typeof value, "function", `export "${name}" must be a function`, ); } }); }); // =========================================================================== // Acceptance probes — every rule at default (block), real hook shape // =========================================================================== describe("Acceptance probes (every rule at default, real hook shape)", () => { const SESSION = "ses_accept"; it("task with no category, subagent_type or task_id → BLOCK", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null }), directory: dir, }); await assert.rejects( hookDrive(hooks, "task", SESSION, {}), { message: /task\/call_omo_agent needs category or subagent_type \(or task_id for resume\)/ }, ); }); it("task with subagent_type oh-my-claudecode:writer → BLOCK", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); await assert.rejects( hookDrive(hooks, "task", SESSION, { category: "quick", subagent_type: "oh-my-claudecode:writer", prompt: "hello", }), { message: /subagent_type must not start with oh-my-claudecode/ }, ); }); it("bash git push origin x → BLOCK (bash_banned)", async () => { const dir = newDir(); writeConfig(dir, EXAMPLE_CONFIG); const hooks = await Guardrails({ client: realClient(), directory: dir }); await assert.rejects( hookDrive(hooks, "bash", SESSION, { command: "git push origin x" }), { message: /bash\/banned: blocked/ }, ); }); it("bash git stash → BLOCK (bash_banned)", async () => { const dir = newDir(); writeConfig(dir, EXAMPLE_CONFIG); const hooks = await Guardrails({ client: realClient(), directory: dir }); await assert.rejects( hookDrive(hooks, "bash", SESSION, { command: "git stash" }), { message: /bash\/banned: blocked/ }, ); }); it("bash curl -s localhost:8080/health → BLOCK (bash_protected_port)", async () => { const dir = newDir(); writeConfig(dir, EXAMPLE_CONFIG); const hooks = await Guardrails({ client: realClient(), directory: dir }); await assert.rejects( hookDrive(hooks, "bash", SESSION, { command: "curl -s localhost:8080/health", }), { message: /bash\/protected_port: blocked/ }, ); }); it("bash ls → ALLOW", async () => { const dir = newDir(); writeConfig(dir, EXAMPLE_CONFIG); const hooks = await Guardrails({ client: realClient(), directory: dir }); await hookDrive(hooks, "bash", SESSION, { command: "ls" }); // No throw → ALLOW }); it("task with category:quick and correct WORKTREE line → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); // Test multiple valid forms of the WORKTREE line const validLines = [ `WORKTREE: ${wt}. cd there first; never edit under ${dir}/.`, `WORKTREE: ${wt}. cd there`, `WORKTREE: ${wt} -- cd there`, `WORKTREE: ${wt}`, ]; for (const line of validLines) { const prompt = line + "\nRefactor this function"; await hookDrive(hooks, "task", SESSION, { category: "quick", prompt, }); } }); it("task with dotted path in WORKTREE line → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt.v2"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); const correctLine = `WORKTREE: ${wt}. cd there first; never edit under ${dir}/.`; await hookDrive(hooks, "task", SESSION, { category: "quick", prompt: correctLine + "\nRefactor this function", }); }); it("task with no WORKTREE line → REWRITE (verify using hookDrive as a wrapper)", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); const args = { category: "quick", prompt: "hello" }; // hookDrive passes { args } as output, so the hook mutates args.prompt await hooks["tool.execute.before"]( { tool: "task", sessionID: SESSION, callID: "call_rewrite" }, { args }, ); const expectedLine = `WORKTREE: ${wt}. cd there first; never edit under ${dir}/.`; assert.equal(args.prompt, expectedLine + "\nhello"); }); it("task with WORKTREE line and warn mode → ALLOW and LOG", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, { ...EXAMPLE_CONFIG, rules: { task_worktree_line: "warn" } }); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); const badLine = `WORKTREE: /wrong/path. cd there first.`; await hookDrive(hooks, "task", SESSION, { category: "quick", prompt: badLine + "\nhello", }); const logContent = readFileSync(join(dir, ".omo/guardrails.log"), "utf-8"); assert.ok(logContent.includes("task_worktree_line")); }); }); // =========================================================================== // Per-rule positive (blocks) and negative (allows) // =========================================================================== describe("task_needs_agent (R1)", () => { const SESSION = "ses_tna"; it("positive: bare task without category/subagent_type/task_id → BLOCK", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null }), directory: dir, }); await assert.rejects( hookDrive(hooks, "task", SESSION, {}), { message: /task\/call_omo_agent needs category or subagent_type/ }, ); }); it("negative: task with category only → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); await hookDrive(hooks, "task", SESSION, { category: "quick", prompt: "hello", }); }); it("negative: task with subagent_type only → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); await hookDrive(hooks, "task", SESSION, { subagent_type: "explore", prompt: "hello", }); }); it("negative: task with task_id only → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); await hookDrive(hooks, "task", SESSION, { task_id: "task_123", prompt: "hello", }); }); it("negative: task with category + subagent_type → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); await hookDrive(hooks, "task", SESSION, { category: "quick", subagent_type: "explore", prompt: "hello", }); }); }); describe("task_banned_agent (R2)", () => { const SESSION = "ses_tba"; it("positive: oh-my-claudecode:writer subagent → BLOCK", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); await assert.rejects( hookDrive(hooks, "task", SESSION, { category: "quick", subagent_type: "oh-my-claudecode:writer", prompt: "hello", }), { message: /subagent_type must not start with oh-my-claudecode/ }, ); }); it("negative: normal subagent_type → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); await hookDrive(hooks, "task", SESSION, { category: "quick", subagent_type: "build", prompt: "hello", }); }); }); describe("task_worktree_line (R3)", () => { const SESSION = "ses_twl"; it("positive: WORKTREE line with wrong path → BLOCK", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); const badLine = `WORKTREE: /some/other/path. cd there first; never edit under /some/other/.`; await assert.rejects( hookDrive(hooks, "task", SESSION, { category: "quick", subagent_type: "explore", prompt: badLine + "\nhello", }), { message: /WORKTREE line path.*does not match/ }, ); }); it("negative: correct WORKTREE line in prompt → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null, id: SESSION }), directory: dir, }); const dirPath = wt.slice(0, wt.lastIndexOf("/")) || wt; const correctLine = `WORKTREE: ${wt}. cd there first; never edit under ${dirPath}/.`; await hookDrive(hooks, "task", SESSION, { category: "quick", subagent_type: "build", prompt: correctLine + "\nRefactor this function", }); }); }); describe("write_outside_worktree (R4)", () => { const SESSION = "ses_wow"; it("positive: write to directory outside worktree → BLOCK", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null }), directory: dir, }); await assert.rejects( hookDrive(hooks, "write", SESSION, { filePath: join(dir, "main.py"), content: "hello", }), { message: /write\/outside_worktree/ }, ); }); it("negative: write inside worktree → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null }), directory: dir, }); await hookDrive(hooks, "write", SESSION, { filePath: join(wt, "a.py"), content: "hello", }); }); }); describe("bash_main_checkout (R5a)", () => { const SESSION = "ses_bmc"; it("positive: git operation in main checkout → BLOCK", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null }), directory: dir, }); await assert.rejects( hookDrive(hooks, "bash", SESSION, { command: "git add ." }), { message: /bash\/main_checkout/ }, ); }); it("negative: git -C worktree → ALLOW", async () => { const dir = newDir(); const wt = join(dir, "wt"); mkdirSync(wt, { recursive: true }); writeConfig(dir, EXAMPLE_CONFIG); writeBoulder(dir, { status: "active", worktree_path: wt, session_ids: [`opencode:${SESSION}`], }); const hooks = await Guardrails({ client: realClient({ parentID: null }), directory: dir, }); await hookDrive(hooks, "bash", SESSION, { command: `git -C ${wt} add .`, }); }); }); describe("bash_banned (R5b)", () => { const SESSION = "ses_bb"; it("positive: banned git push → BLOCK", async () => { const dir = newDir(); writeConfig(dir, EXAMPLE_CONFIG); const hooks = await Guardrails({ client: realClient(), directory: dir }); await assert.rejects( hookDrive(hooks, "bash", SESSION, { command: "git push origin main" }), { message: /bash\/banned: blocked/ }, ); }); it("negative: safe command ls → ALLOW", async () => { const dir = newDir(); writeConfig(dir, EXAMPLE_CONFIG); const hooks = await Guardrails({ client: realClient(), directory: dir }); await hookDrive(hooks, "bash", SESSION, { command: "ls -la" }); }); }); describe("bash_protected_port (R5c)", () => { const SESSION = "ses_bpp"; it("positive: curl to localhost:8080 → BLOCK", async () => { const dir = newDir(); writeConfig(dir, EXAMPLE_CONFIG); const hooks = await Guardrails({ client: realClient(), directory: dir }); await assert.rejects( hookDrive(hooks, "bash", SESSION, { command: "curl -s http://127.0.0.1:8080/health", }), { message: /bash\/protected_port/ }, ); }); it("negative: curl to non-protected port → ALLOW", async () => { const dir = newDir(); writeConfig(dir, EXAMPLE_CONFIG); const hooks = await Guardrails({ client: realClient(), directory: dir }); await hookDrive(hooks, "bash", SESSION, { command: "curl -s http://127.0.0.1:9999/health", }); }); }); // =========================================================================== // Tick gate — real scripts/verify_commit.py (only test that may use it) // =========================================================================== describe("plan_tick_gate with real verify_commit.py", () => { const SESSION = "ses_ptg"; /** * Set up a minimal git repo in wtDir with a commit and a plan file. */ async function setupTickRepo(wtDir, planPath) { await execFileAsync("git", ["-C", wtDir, "init"]); await execFileAsync("git", ["-C", wtDir, "config", "user.email", "test@test.com"]); await execFileAsync("git", ["-C", wtDir, "config", "user.name", "Test"]); writeFileSync(join(wtDir, "README.md"), "# Test repo"); await execFileAsync("git", ["-C", wtDir, "add", "README.md"]); await execFileAsync("git", ["-C", wtDir, "commit", "-m", "init"]); mkdirSync(join(wtDir, ".omo"), { recursive: true }); writeFileSync(planPath, "- [ ] 1. do it"); // Commit plan so HEAD has a valid commit tree with it await execFileAsync("git", ["-C", wtDir, "add", ".omo/PLAN.md"]); await execFileAsync("git", ["-C", wtDir, "commit", "-m", "add plan"]); } it("allows tick when verify_commit passes (clean tree)", async () => { const dir = newDir(); const wtDir = join(dir, "wt"); mkdirSync(wtDir, { recursive: true }); // Copy verify_commit.py so the default tick gate command resolves const scriptsDest = join(dir, "scripts"); mkdirSync(scriptsDest, { recursive: true }); copyFileSync( join(SCRIPT_DIR, "verify_commit.py"), join(scriptsDest, "verify_commit.py"), ); const planPath = join(wtDir, ".omo", "PLAN.md"); await setupTickRepo(wtDir, planPath); // Temporarily override tick_gate to point at our copied script writeConfig(dir, { ...EXAMPLE_CONFIG, tick_gate: { cmd: [ PYTHON3, "{directory}/scripts/verify_commit.py", "--repo", "{worktree}", "--require-clean", "HEAD", ], timeout_s: 30, }, }); writeBoulder(dir, { status: "active", worktree_path: wtDir, session_ids: [`opencode:${SESSION}`], active_plan: planPath, }); const hooks = await Guardrails({ client: realClient({ parentID: null }), directory: dir, }); // Tick the todo: - [ ] 1. do it → - [x] 1. do it await hookDrive(hooks, "write", SESSION, { filePath: planPath, oldString: "- [ ] 1. do it", newString: "- [x] 1. do it", }); // No throw → ALLOW }); it("blocks tick when verify_commit fails (dirty tree)", async () => { const dir = newDir(); const wtDir = join(dir, "wt"); mkdirSync(wtDir, { recursive: true }); const scriptsDest = join(dir, "scripts"); mkdirSync(scriptsDest, { recursive: true }); copyFileSync( join(SCRIPT_DIR, "verify_commit.py"), join(scriptsDest, "verify_commit.py"), ); const planPath = join(wtDir, ".omo", "PLAN.md"); await setupTickRepo(wtDir, planPath); // Make the tree dirty (modify a tracked file without committing) writeFileSync(join(wtDir, "README.md"), "# Dirty"); writeConfig(dir, { ...EXAMPLE_CONFIG, tick_gate: { cmd: [ PYTHON3, "{directory}/scripts/verify_commit.py", "--repo", "{worktree}", "--require-clean", "HEAD", ], timeout_s: 30, }, }); writeBoulder(dir, { status: "active", worktree_path: wtDir, session_ids: [`opencode:${SESSION}`], active_plan: planPath, }); const hooks = await Guardrails({ client: realClient({ parentID: null }), directory: dir, }); await assert.rejects( hookDrive(hooks, "write", SESSION, { filePath: planPath, oldString: "- [ ] 1. do it", newString: "- [x] 1. do it", }), { message: /plan_tick_gate/ }, ); }); });