/** * Tests for guardrails.js * * Transitions from stubClient to file-based boulder snapshots. */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { writeFileSync, existsSync, readFileSync, mkdirSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { mkdtempSync } from "node:fs"; import os from "node:os"; import { Guardrails } from "./guardrails.js"; // Stub SDK client for parentID walk function stubClient(sessionGetImpl) { return { session: { get: sessionGetImpl, }, }; } function newDir() { return mkdtempSync(join(os.tmpdir(), "guardrails-test-")); } function writeConfig(dir, configObj) { const cfgDir = join(dir, ".omo"); mkdirSync(cfgDir, { recursive: true }); writeFileSync(join(cfgDir, "guardrails.json"), JSON.stringify(configObj)); return { cfgDir, logPath: join(cfgDir, "guardrails.log") }; } function writeBoulder(dir, boulderObj) { const boulderDir = join(dir, ".omo"); if (!existsSync(boulderDir)) { mkdirSync(boulderDir, { recursive: true }); } writeFileSync(join(boulderDir, "boulder.json"), JSON.stringify(boulderObj)); } function callHook(hooks, sessionID, toolName, args, output) { const out = output ?? {}; if (typeof out.args !== "object" || out.args === null) { out.args = args ?? {}; } return hooks["tool.execute.before"]( { sessionID, tool: toolName }, out, ); } // --------------------------------------------------------------------------- // no-config => no-op // --------------------------------------------------------------------------- describe("no config", () => { it("is a no-op when config file is absent", async () => { const dir = newDir(); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); assert.ok(hooks["tool.execute.before"]); await callHook(hooks, "ses_noconfig_001", "bash", { command: "echo hi" }, {}); }); }); // --------------------------------------------------------------------------- // unparsable config => no-op plus one log line // --------------------------------------------------------------------------- describe("unparsable config", () => { it("treats non-JSON config as absent and logs a warning once", async () => { const dir = newDir(); const cfgDir = join(dir, ".omo"); mkdirSync(cfgDir, { recursive: true }); const cfgPath = join(cfgDir, "guardrails.json"); const logPath = join(cfgDir, "guardrails.log"); writeFileSync(cfgPath, "not json at all {{{"); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_badcfg_001", "bash", { command: "echo hi" }, {}); assert.equal(existsSync(logPath), true); const logContent = readFileSync(logPath, "utf-8"); assert.ok( logContent.toLowerCase().includes("unparsable"), "log should contain 'unparsable'", ); }); }); // --------------------------------------------------------------------------- // internal exception => allow call + log internal_error // --------------------------------------------------------------------------- describe("internal exception", () => { it("allows the call when session store throws and logs internal_error", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: {} }); // Write a boulder to disk so readBoulder finds it (readBoulder // reads from the filesystem, not from the client). writeBoulder(dir, { status: "active", session_ids: ["ses_some_other_session"], worktree_path: dir, }); // Stub client throws on any call => triggers ancestor-walk catch // inside checkScope, which treats the session as in-scope. const scopeClient = stubClient(async () => { throw new Error("session store unreachable"); }); const hooks = await Guardrails({ client: scopeClient, directory: dir, }); // checkScope catches the ancestor-walk throw => inScope=true. // The hook then checks output.args. Bypass callHook's normalization // by calling the hook directly with output = {} (no `args` key), // which triggers __internal_error__ at the "missing or invalid output.args" check. await hooks["tool.execute.before"]( { sessionID: "ses_broken_001", tool: "bash" }, {}, ); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const errorEntry = JSON.parse(lines[lines.length - 1]); assert.equal(errorEntry.rule, "__internal_error__"); assert.ok(errorEntry.detail.includes("missing or invalid output.args")); }); }); // --------------------------------------------------------------------------- // loader contract -- every export is a function // --------------------------------------------------------------------------- describe("loader contract", () => { it("every named export from the module is a function (opencode rejects non-function exports)", 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`, ); } }); }); // --------------------------------------------------------------------------- // task_needs_agent -- block mode // --------------------------------------------------------------------------- describe("task_needs_agent", () => { it("blocks when task has no category and no subagent_type", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_needs_agent: "block" } }); const worktreePath = join(dir, "wt"); mkdirSync(worktreePath, { recursive: true }); writeBoulder(dir, { status: "active", session_ids: ["ses_na_001"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_na_001", "task", { prompt: "do X" }, {}), { message: "task/call_omo_agent needs category or subagent_type (or task_id for resume)" }, ); }); it("allows task with category only", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_needs_agent: "block" } }); const worktreePath = join(dir, "wt"); mkdirSync(worktreePath, { recursive: true }); writeBoulder(dir, { status: "active", session_ids: ["ses_na_002"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // category is enough -- guardrails only blocks when ALL of category/subagent_type/task_id are missing await callHook(hooks, "ses_na_002", "task", { prompt: "do X", category: "quick" }, {}); }); it("allows task with subagent_type only", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_needs_agent: "block" } }); const worktreePath = join(dir, "wt"); mkdirSync(worktreePath, { recursive: true }); writeBoulder(dir, { status: "active", session_ids: ["ses_na_003"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // subagent_type is enough -- guardrails only blocks when ALL of category/subagent_type/task_id are missing await callHook(hooks, "ses_na_003", "task", { prompt: "do X", subagent_type: "explore" }, {}); }); }); // --------------------------------------------------------------------------- // task_needs_agent -- task_id resume exempt // --------------------------------------------------------------------------- describe("task_needs_agent -- task_id resume", () => { it("allows task with task_id even when category/subagent_type are absent", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_needs_agent: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_na_resume", "task", { prompt: "resume", task_id: "abc123" }, {}); }); }); // --------------------------------------------------------------------------- // task_needs_agent -- warn mode // --------------------------------------------------------------------------- describe("task_needs_agent -- warn mode", () => { it("allows call and logs when mode is warn", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { task_needs_agent: "warn" } }); const worktreePath = join(dir, "wt"); mkdirSync(worktreePath, { recursive: true }); writeBoulder(dir, { status: "active", session_ids: ["ses_na_warn"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // Must not throw await callHook(hooks, "ses_na_warn", "task", { prompt: "do X" }, {}); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const warnEntry = JSON.parse(lines[lines.length - 1]); assert.equal(warnEntry.rule, "task_needs_agent"); }); }); // --------------------------------------------------------------------------- // task_needs_agent -- off mode // --------------------------------------------------------------------------- describe("task_needs_agent -- off mode", () => { it("allows call silently when mode is off", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_needs_agent: "off" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_na_off", "task", { prompt: "do X" }, {}); }); }); // --------------------------------------------------------------------------- // task_banned_agent -- block mode // --------------------------------------------------------------------------- describe("task_banned_agent", () => { it("blocks when subagent_type starts with oh-my-claudecode:", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_banned_agent: "block" } }); const worktreePath = join(dir, "wt"); mkdirSync(worktreePath, { recursive: true }); writeBoulder(dir, { status: "active", session_ids: ["ses_tb_001"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_tb_001", "task", { prompt: "do X", category: "quick", subagent_type: "oh-my-claudecode:oracle" }, {}), { message: "subagent_type must not start with oh-my-claudecode: (banned)" }, ); }); it("allows when subagent_type does not start with oh-my-claudecode:", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_banned_agent: "block" } }); const worktreePath = join(dir, "wt"); mkdirSync(worktreePath, { recursive: true }); writeBoulder(dir, { status: "active", session_ids: ["ses_tb_002"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_tb_002", "task", { prompt: "do X", category: "quick", subagent_type: "explore" }, {}); }); }); // --------------------------------------------------------------------------- // task_banned_agent -- warn mode // --------------------------------------------------------------------------- describe("task_banned_agent -- warn mode", () => { it("allows call and logs when mode is warn", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { task_banned_agent: "warn" } }); const worktreePath = join(dir, "wt"); mkdirSync(worktreePath, { recursive: true }); writeBoulder(dir, { status: "active", session_ids: ["ses_tb_warn"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // Must not throw await callHook(hooks, "ses_tb_warn", "task", { prompt: "do X", category: "quick", subagent_type: "oh-my-claudecode:oracle" }, {}); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const warnEntry = JSON.parse(lines[lines.length - 1]); assert.equal(warnEntry.rule, "task_banned_agent"); }); }); // --------------------------------------------------------------------------- // task_banned_agent -- off mode // --------------------------------------------------------------------------- describe("task_banned_agent -- off mode", () => { it("allows call silently when mode is off", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_banned_agent: "off" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_tb_off", "task", { prompt: "do X", category: "quick", subagent_type: "oh-my-claudecode:oracle" }, {}); }); }); // --------------------------------------------------------------------------- // task_worktree_line -- boulder inactive => no-op // --------------------------------------------------------------------------- describe("task_worktree_line -- inactive boulder", () => { it("does nothing when boulder is inactive", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_worktree_line: "block" } }); writeBoulder(dir, { status: "idle", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // Should not throw; output unchanged await callHook(hooks, "ses_tw_001", "task", { prompt: "do X", category: "quick", subagent_type: "explore" }, {}); }); }); // --------------------------------------------------------------------------- // task_worktree_line -- mismatched WORKTREE path blocks // --------------------------------------------------------------------------- describe("task_worktree_line -- mismatched path", () => { it("blocks when existing WORKTREE line points to a different path", async () => { const dir = newDir(); const worktreePath = "/home/alee/Sources/6krrt-worktrees/agent-guardrails"; writeConfig(dir, { rules: { task_worktree_line: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_tw_002"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_tw_002", "task", { prompt: "WORKTREE: /wrong/path. cd there first.\nOriginal prompt", category: "quick", subagent_type: "explore" }, {}), /WORKTREE line path .* does not match expected/, ); }); }); // --------------------------------------------------------------------------- // task_worktree_line -- no active boulder => no-op // --------------------------------------------------------------------------- describe("task_worktree_line -- no active boulder", () => { it("does nothing when boulder is absent", async () => { const dir = newDir(); writeConfig(dir, { rules: { task_worktree_line: "block" } }); const hooks = await Guardrails({ client: stubClient(async () => ({ data: {} })), directory: dir, }); // Should not throw await callHook(hooks, "ses_tw_003", "task", { prompt: "do X", category: "quick", subagent_type: "explore" }, {}); }); }); // --------------------------------------------------------------------------- // task_worktree_line -- prompt rewrite (prepend) // --------------------------------------------------------------------------- describe("task_worktree_line -- rewrite", () => { it("prepends WORKTREE line when prompt lacks one", async () => { const dir = newDir(); const worktreePath = "/home/alee/Sources/6krrt-worktrees/agent-guardrails"; writeConfig(dir, { rules: { task_worktree_line: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_tw_004"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); const output = { args: { category: "quick", prompt: "do X" } }; await callHook(hooks, "ses_tw_004", "task", { prompt: "do X", category: "quick", subagent_type: "explore" }, output); assert.equal( output.args.prompt, `WORKTREE: ${worktreePath}. cd there first; never edit under ${dir}/.\ndo X`, ); }); }); // --------------------------------------------------------------------------- // write_outside_worktree -- block mode // --------------------------------------------------------------------------- describe("write_outside_worktree -- block mode", () => { it("blocks write to main checkout when boulder has worktree_path", async () => { const dir = newDir(); const mainCheckout = dir; // directory = main checkout const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_wow_001"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: mainCheckout, }); const targetFile = join(mainCheckout, "src", "a.py"); await assert.rejects( callHook(hooks, "ses_wow_001", "write", { filePath: targetFile, content: "x" }, {}), { message: "write/outside_worktree: write to " + targetFile + " blocked. Work is in worktree " + worktreePath + "; use that instead." }, ); }); it("blocks edit to main checkout when boulder has worktree_path", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_wow_002"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); const targetFile = join(dir, "src", "a.py"); await assert.rejects( callHook(hooks, "ses_wow_002", "edit", { filePath: targetFile, oldString: "", newString: "" }, {}), { message: "write/outside_worktree: write to " + targetFile + " blocked. Work is in worktree " + worktreePath + "; use that instead." }, ); }); it("allows write to .omo/ inside directory", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); const targetFile = join(dir, ".omo", "guardrails.json"); await callHook(hooks, "ses_wow_003", "write", { filePath: targetFile, content: "{}" }, {}); }); it("allows write to worktree (outside directory)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); const targetFile = join(worktreePath, "deploy", "opencode-plugin", "guardrails.js"); await callHook(hooks, "ses_wow_004", "write", { filePath: targetFile, content: "x" }, {}); }); it("blocks relative filePath resolving against directory", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_wow_005"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // Relative path resolves against directory = blocked await assert.rejects( callHook(hooks, "ses_wow_005", "write", { filePath: "src/a.py", content: "x" }, {}), { message: "write/outside_worktree: write to " + join(dir, "src/a.py") + " blocked. Work is in worktree " + worktreePath + "; use that instead." }, ); }); it("allows relative filePath that resolves to worktree when worktree is within directory", async () => { const dir = newDir(); // Simulate worktree nested inside directory (unusual but valid) const worktreePath = join(dir, "worktrees", "my-worktree"); mkdirSync(join(worktreePath, ".git"), { recursive: true }); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); const targetFile = join(worktreePath, "src", "a.py"); await callHook(hooks, "ses_wow_006", "write", { filePath: targetFile, content: "x" }, {}); }); }); // --------------------------------------------------------------------------- // write_outside_worktree -- inactive boulder => no-op // --------------------------------------------------------------------------- describe("write_outside_worktree -- inactive boulder", () => { it("does nothing when boulder is inactive", async () => { const dir = newDir(); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "idle", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_wow_inactive", "write", { filePath: join(dir, "src/a.py"), content: "x" }, {}); }); }); // --------------------------------------------------------------------------- // write_outside_worktree -- no worktree_path => no-op // --------------------------------------------------------------------------- describe("write_outside_worktree -- no worktree_path", () => { it("does nothing when boulder lacks worktree_path", async () => { const dir = newDir(); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_wow_no_wt", "write", { filePath: join(dir, "src/a.py"), content: "x" }, {}); }); }); // --------------------------------------------------------------------------- // write_outside_worktree -- off mode // --------------------------------------------------------------------------- describe("write_outside_worktree -- off mode", () => { it("allows silently when mode is off", async () => { const dir = newDir(); writeConfig(dir, { rules: { write_outside_worktree: "off" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: "/some/worktree" }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_wow_off", "write", { filePath: join(dir, "src/a.py"), content: "x" }, {}); }); }); // --------------------------------------------------------------------------- // write_outside_worktree -- warn mode // --------------------------------------------------------------------------- describe("write_outside_worktree -- warn mode", () => { it("allows and logs when mode is warn", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { write_outside_worktree: "warn" } }); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeBoulder(dir, { status: "active", session_ids: ["ses_wow_warn"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_wow_warn", "write", { filePath: join(dir, "src/a.py"), content: "x" }, {}); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const warnEntry = JSON.parse(lines[lines.length - 1]); assert.equal(warnEntry.rule, "write_outside_worktree"); }); }); // --------------------------------------------------------------------------- // bash_main_checkout -- Trigger 1: git operations // --------------------------------------------------------------------------- describe("bash_main_checkout -- Trigger 1: git operations", () => { it("blocks git commit after cd to main checkout", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_001"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); const cmd = `cd ${dir} && git commit -m "msg"`; await assert.rejects( callHook(hooks, "ses_bmc_001", "bash", { command: cmd, workdir: dir }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); }); it("blocks git add in directory without explicit cd (workdir matches directory)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_002"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_bmc_002", "bash", { command: "git add src/a.py", workdir: dir }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); }); it("blocks git reset, merge, rebase, etc. in directory", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_reset", "ses_bmc_merge", "ses_bmc_rebase", "ses_bmc_cherry-pick", "ses_bmc_rm", "ses_bmc_mv", "ses_bmc_stash", "ses_bmc_checkout", "ses_bmc_switch"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); for (const op of ["reset", "merge", "rebase", "cherry-pick", "rm", "mv", "stash", "checkout", "switch"]) { await assert.rejects( callHook(hooks, "ses_bmc_" + op, "bash", { command: "git " + op + " foo", workdir: dir }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); } }); it("allows git -C commit (CWD overridden to worktree)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // git -C overrides CWD to worktree, so no block await callHook(hooks, "ses_bmc_gitc", "bash", { command: "git -C " + worktreePath + " commit -m msg" }, {}); }); it("allows git commit when workdir is the worktree (not directory)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // workdir = worktree, so effective CWD = worktree != directory => allow await callHook(hooks, "ses_bmc_wd", "bash", { command: "git commit -m msg", workdir: worktreePath }, {}); }); }); // --------------------------------------------------------------------------- // bash_main_checkout -- git -c (config) and env wrapper bypasses // --------------------------------------------------------------------------- describe("bash_main_checkout -- git -c and env wrappers", () => { it("blocks git -c user.name=x commit -m y when cwd is main checkout", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_gc_block"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // git -c user.name=x should not prevent the block: -c is config, not -C await assert.rejects( callHook(hooks, "ses_gc_block", "bash", { command: "git -c user.name=x commit -m y", workdir: dir }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); }); it("blocks git -c a=b -c c=d add . when cwd is main checkout", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_gcc_block"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // Multiple -c options should not be confused with -C await assert.rejects( callHook(hooks, "ses_gcc_block", "bash", { command: "git -c a=b -c c=d add .", workdir: dir }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); }); it("blocks env GIT_X=1 git stash when cwd is main checkout", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_env_block"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // env wrapper should be stripped, exposing git stash await assert.rejects( callHook(hooks, "ses_env_block", "bash", { command: "env GIT_X=1 git stash", workdir: dir }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); }); it("blocks env -i git add . when cwd is main checkout", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_envi_block"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // env -i wrapper should be stripped, exposing git add . await assert.rejects( callHook(hooks, "ses_envi_block", "bash", { command: "env -i git add .", workdir: dir }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); }); it("allows git -c user.name=x -C add src/a.py", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // -C overrides CWD even with -c present; -C is uppercase, not -c await callHook(hooks, "ses_gc_allow", "bash", { command: "git -c user.name=x -C " + worktreePath + " add src/a.py", workdir: dir }, {}); }); it("allows env GIT_X=1 git -C stash list", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // env wrapper stripped, -C overrides CWD to worktree await callHook(hooks, "ses_env_allow", "bash", { command: "env GIT_X=1 git -C " + worktreePath + " stash list", workdir: dir }, {}); }); }); // --------------------------------------------------------------------------- // bash_main_checkout -- Trigger 1: bare keywords (false positives) // --------------------------------------------------------------------------- describe("bash_main_checkout -- Trigger 1: bare keywords do not block", () => { it("allows grep -n commit AGENTS.md (keyword in argument, not git subcommand)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_grep"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_grep", "bash", { command: 'grep -n "commit" AGENTS.md', workdir: dir }, {}); }); it("allows echo add a line (keyword in quoted string, not git subcommand)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_echo"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_echo", "bash", { command: "echo add a line", workdir: dir }, {}); }); it("allows python3 -c with reset keyword (not a git command)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_python"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_python", "bash", { command: "python3 -c \"print('reset')\"", workdir: dir }, {}); }); it("allows mv /tmp/a /tmp/b (mv as shell builtin, not git mv)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_mv"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_mv", "bash", { command: "mv /tmp/a /tmp/b", workdir: dir }, {}); }); it("allows sed 's/merge/master/g' (keyword in sed expression, not git subcommand)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_sed"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_sed", "bash", { command: "sed 's/merge/master/g' file.txt", workdir: dir }, {}); }); it("allows grep merge-sort data.csv (merge in word, not git merge)", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_merge_sort"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_merge_sort", "bash", { command: "grep merge-sort data.csv", workdir: dir }, {}); }); }); // --------------------------------------------------------------------------- // bash_main_checkout -- Trigger 2: redirections // --------------------------------------------------------------------------- describe("bash_main_checkout -- Trigger 2: redirections", () => { it("blocks echo x >
/src/a.py", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_redir_001"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); const cmd = "echo x > " + dir + "/src/a.py"; await assert.rejects( callHook(hooks, "ses_bmc_redir_001", "bash", { command: cmd }, {}), { message: "bash/main_checkout: redirect to " + dir + "/src/a.py" + " blocked. Work is in worktree " + worktreePath + "; use that instead." }, ); }); it("blocks tee to file inside directory outside .omo/", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_redir_002"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); const cmd = "echo x | tee " + dir + "/output.txt"; await assert.rejects( callHook(hooks, "ses_bmc_redir_002", "bash", { command: cmd }, {}), { message: "bash/main_checkout: redirect to " + dir + "/output.txt" + " blocked. Work is in worktree " + worktreePath + "; use that instead." }, ); }); it("allows > /dev/null", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_devnull", "bash", { command: "> /dev/null" }, {}); }); it("allows > /tmp/x", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_tmp", "bash", { command: "> /tmp/x" }, {}); }); it("allows redirect to .omo/ file", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_omo", "bash", { command: "echo x > " + dir + "/.omo/guardrails.json" }, {}); }); }); // --------------------------------------------------------------------------- // bash_main_checkout -- effective CWD tracking // --------------------------------------------------------------------------- describe("bash_main_checkout -- effective CWD tracking", () => { it("git -C overrides cd and workdir", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // git -C points to worktree, so CWD is worktree, not directory await callHook(hooks, "ses_bmc_gitc_override", "bash", { command: "git -C " + worktreePath + " add src/a.py", workdir: dir }, {}); }); it("cd from worktree to main triggers block", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_cd_override"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // cd to directory overrides workdir const cmd = "cd " + dir + " && git commit -m msg"; await assert.rejects( callHook(hooks, "ses_bmc_cd_override", "bash", { command: cmd, workdir: worktreePath }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); }); it("no cd, no git -C => uses workdir", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // workdir = worktreePath, so effective CWD = worktreePath != directory => allow await callHook(hooks, "ses_bmc_workdir", "bash", { command: "git commit -m msg", workdir: worktreePath }, {}); }); it("no cd, no workdir => uses directory (main checkout) => blocks", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_default"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // No workdir => effective CWD = directory => block await assert.rejects( callHook(hooks, "ses_bmc_default", "bash", { command: "git commit -m msg" }, {}), { message: "bash/main_checkout: git operation blocked in " + dir + ". Work is in worktree " + worktreePath + "; use git -C " + worktreePath + " instead." }, ); }); }); // --------------------------------------------------------------------------- // bash_main_checkout -- m2 quoted/heredoc cases // --------------------------------------------------------------------------- describe("bash_main_checkout -- m2 quoted/heredoc cases", () => { it("allows redirects inside quotes", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_m2_allow"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); const allowedCmds = [ `python3 -c "import json; d=json.load(open('${dir}/x.json')); print(len(d) > 300)"`, `python3 -c "print(sum(1 for c in open('${dir}/f').read() if ord(c) > 127))"`, `node -e "const x=[1]; console.log(x.length > 0)"`, `awk '$1 > 5' ${dir}/data.txt`, ]; for (const cmd of allowedCmds) { await callHook(hooks, "ses_m2_allow", "bash", { command: cmd }, {}); } }); it("allows redirects inside heredoc bodies", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_m2_heredoc"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); const cmd = `cat > /tmp/f.mjs << 'EOF'\necho x > ${dir}/src/test.py\nEOF`; await callHook(hooks, "ses_m2_heredoc", "bash", { command: cmd }, {}); }); it("blocks real redirects to main checkout", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_m2_block"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); const blockedCmds = [ `echo x > ${dir}/src/a.py`, `echo x >> ${dir}/src/a.py`, `python3 -c "print(1)" > ${dir}/out.txt`, `cmd 2> ${dir}/err.txt`, `cmd &> ${dir}/o.txt`, `echo x | tee ${dir}/src/t.txt`, ]; for (const cmd of blockedCmds) { await assert.rejects( callHook(hooks, "ses_m2_block", "bash", { command: cmd }, {}), /bash\/main_checkout: redirect to .* blocked/, ); } }); it("allows redirects to worktree or /tmp", async () => { const dir = newDir(); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "active", session_ids: ["ses_m2_worktree_tmp"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); const allowedCmds = [ `echo x > ${worktreePath}/src/a.py`, `echo x >> ${worktreePath}/src/a.py`, `echo x > /tmp/out.txt`, ]; for (const cmd of allowedCmds) { await callHook(hooks, "ses_m2_worktree_tmp", "bash", { command: cmd }, {}); } }); }); // --------------------------------------------------------------------------- describe("bash_main_checkout -- inactive boulder", () => { it("does nothing when boulder is inactive", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_main_checkout: "block" } }); writeBoulder(dir, { status: "idle", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_inactive", "bash", { command: "git commit -m msg" }, {}); }); }); describe("bash_main_checkout -- off mode", () => { it("allows silently when mode is off", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_main_checkout: "off" } }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: "/some/worktree" }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_off", "bash", { command: "git commit -m msg" }, {}); }); }); describe("bash_main_checkout -- warn mode", () => { it("allows and logs when mode is warn", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { bash_main_checkout: "warn" } }); const worktreePath = join(os.homedir(), "Sources/6krrt-worktrees/agent-guardrails"); writeBoulder(dir, { status: "active", session_ids: ["ses_bmc_warn"], worktree_path: worktreePath }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bmc_warn", "bash", { command: "git commit -m msg" }, {}); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const warnEntry = JSON.parse(lines[lines.length - 1]); assert.equal(warnEntry.rule, "bash_main_checkout"); }); }); // --------------------------------------------------------------------------- // bash_protected_port -- block and allow cases // --------------------------------------------------------------------------- describe("bash_protected_port", () => { const config = { rules: { bash_protected_port: "block" }, protected_ports: [8080], }; const setup = async () => { const dir = newDir(); writeConfig(dir, config); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); return { hooks, dir }; }; it("blocks curl to protected port", async () => { const { hooks } = await setup(); await assert.rejects( callHook(hooks, "ses_pp_001", "bash", { command: "curl -s localhost:8080/health" }, {}), { message: "bash/protected_port: blocked -- access to port 8080" }, ); }); it("blocks curl to protected port via 127.0.0.1", async () => { const { hooks } = await setup(); await assert.rejects( callHook(hooks, "ses_pp_002", "bash", { command: "curl -s http://127.0.0.1:8080/x | jq ." }, {}), { message: "bash/protected_port: blocked -- access to port 8080" }, ); }); it("blocks curl to protected port with quotes", async () => { const { hooks } = await setup(); await assert.rejects( callHook(hooks, "ses_pp_003", "bash", { command: "curl -s \"http://localhost:8080/v1/models\"" }, {}), { message: "bash/protected_port: blocked -- access to port 8080" }, ); }); it("blocks curl with heredoc targeting protected port", async () => { const { hooks } = await setup(); await assert.rejects( callHook(hooks, "ses_pp_004", "bash", { command: "curl -s localhost:8080/x << 'EOF'\nbody\nEOF" }, {}), { message: "bash/protected_port: blocked -- access to port 8080" }, ); }); it("blocks bash heredoc containing protected port", async () => { const { hooks } = await setup(); await assert.rejects( callHook(hooks, "ses_pp_005", "bash", { command: "bash << 'EOF'\ncurl localhost:8080\nEOF" }, {}), { message: "bash/protected_port: blocked -- access to port 8080" }, ); }); it("blocks python -c using protected port", async () => { const { hooks } = await setup(); await assert.rejects( callHook(hooks, "ses_pp_006", "bash", { command: "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/x')\"" }, {}), { message: "bash/protected_port: blocked -- access to port 8080" }, ); }); it("allows curl to non-protected port", async () => { const { hooks } = await setup(); await callHook(hooks, "ses_pp_007", "bash", { command: "curl -s localhost:8081/health" }, {}); }); it("allows echo mentioning protected port", async () => { const { hooks } = await setup(); await callHook(hooks, "ses_pp_008", "bash", { command: "echo \"router is on localhost:8080\"" }, {}); }); it("allows grep mentioning protected port", async () => { const { hooks } = await setup(); await callHook(hooks, "ses_pp_009", "bash", { command: "grep -n \"localhost:8080\" README.md" }, {}); }); it("allows rg mentioning protected port", async () => { const { hooks } = await setup(); await callHook(hooks, "ses_pp_010", "bash", { command: "rg localhost:8080 docs/" }, {}); }); it("allows non-interpreter heredoc mentioning protected port", async () => { const { hooks } = await setup(); await callHook(hooks, "ses_pp_011", "bash", { command: "cat > /tmp/x.mjs << 'EOF'\nhttp://localhost:8080/health\nEOF" }, {}); }); }); describe("helper functions", () => { it("resolvePath passes absolute paths through", () => { assert.equal(Guardrails.resolvePath("/home/alee/file.py", "/home/alee/dir"), "/home/alee/file.py"); }); it("resolvePath resolves relative paths against directory", () => { assert.equal(Guardrails.resolvePath("src/a.py", "/home/alee/dir"), "/home/alee/dir/src/a.py"); }); it("isInside returns true for same path", () => { assert.equal(Guardrails.isInside("/home/alee/dir", "/home/alee/dir"), true); }); it("isInside returns true for child path", () => { assert.equal(Guardrails.isInside("/home/alee/dir/sub/file.py", "/home/alee/dir"), true); }); it("isInside returns false for sibling path", () => { assert.equal(Guardrails.isInside("/home/alee/other/file.py", "/home/alee/dir"), false); }); it("isInside returns false for null/undefined", () => { assert.equal(Guardrails.isInside(null, "/home/alee/dir"), false); assert.equal(Guardrails.isInside("/home/alee/dir", null), false); }); it("resolveEffectiveCwd respects git -C override", () => { assert.equal( Guardrails.resolveEffectiveCwd("git -C /worktree commit -m msg", "/other", "/main"), "/worktree", ); }); it("resolveEffectiveCwd uses last cd segment", () => { assert.equal( Guardrails.resolveEffectiveCwd("cd /main && git add .", "/other", "/main"), "/main", ); }); it("resolveEffectiveCwd falls back to workdir", () => { assert.equal( Guardrails.resolveEffectiveCwd("echo hi", "/workdir", "/main"), "/workdir", ); }); it("resolveEffectiveCwd falls back to directory", () => { assert.equal( Guardrails.resolveEffectiveCwd("echo hi", "", "/main"), "/main", ); }); it("findRedirectTargets extracts > targets", () => { assert.deepEqual( Guardrails.findRedirectTargets("echo x > /main/out.txt", "/cwd"), ["/main/out.txt"], ); }); it("findRedirectTargets extracts >> targets", () => { assert.deepEqual( Guardrails.findRedirectTargets("echo x >> /main/out.txt", "/cwd"), ["/main/out.txt"], ); }); it("findRedirectTargets extracts tee targets", () => { assert.deepEqual( Guardrails.findRedirectTargets("echo x | tee /main/out.txt", "/cwd"), ["/main/out.txt"], ); }); it("findRedirectTargets skips 2>&1", () => { assert.deepEqual( Guardrails.findRedirectTargets("echo x 2>&1", "/cwd"), [], ); }); }); // --------------------------------------------------------------------------- // stripQuotedStrings -- helper function // --------------------------------------------------------------------------- describe("stripQuotedStrings", () => { it("strips single-quoted strings", () => { assert.equal(Guardrails.stripQuotedStrings("echo 'hello world'"), "echo "); }); it("strips double-quoted strings", () => { assert.equal(Guardrails.stripQuotedStrings('echo "hello world"'), "echo "); }); it("strips backtick-quoted strings", () => { assert.equal(Guardrails.stripQuotedStrings("echo `whoami`"), "echo "); }); it("strips multiple quoted strings", () => { assert.equal( Guardrails.stripQuotedStrings("echo 'a' && echo 'b'"), "echo && echo ", ); }); it("preserves unquoted content", () => { assert.equal(Guardrails.stripQuotedStrings("git add src/a.py"), "git add src/a.py"); }); it("handles mixed quotes", () => { assert.equal( Guardrails.stripQuotedStrings('echo "a" \'b\' `c`'), "echo ", ); }); }); // --------------------------------------------------------------------------- // splitSegments -- helper function // --------------------------------------------------------------------------- describe("splitSegments", () => { it("splits on &&", () => { assert.deepEqual(Guardrails.splitSegments("a && b"), ["a", "b"]); }); it("splits on ;", () => { assert.deepEqual(Guardrails.splitSegments("a ; b"), ["a", "b"]); }); it("splits on ||", () => { assert.deepEqual(Guardrails.splitSegments("a || b"), ["a", "b"]); }); it("splits on |", () => { assert.deepEqual(Guardrails.splitSegments("a | b"), ["a", "b"]); }); it("splits on newline", () => { assert.deepEqual(Guardrails.splitSegments("a\nb"), ["a", "b"]); }); it("handles multiple delimiters", () => { assert.deepEqual(Guardrails.splitSegments("a && b ; c || d | e"), ["a", "b", "c", "d", "e"]); }); it("does NOT split on newline inside double-quoted string", () => { const cmd = 'node -e "\nconsole.log(\'hi\')\n"'; const segs = Guardrails.splitSegments(cmd); assert.equal(segs.length, 1, "multi-line quoted string should be one segment"); }); it("does NOT split on newline inside single-quoted string", () => { const cmd = "node -e '\nconsole.log(1)\n'"; const segs = Guardrails.splitSegments(cmd); assert.equal(segs.length, 1, "multi-line quoted string should be one segment"); }); it("does NOT split on newline inside backtick-quoted string", () => { const cmd = "node -e `\nconsole.log(1)\n`"; const segs = Guardrails.splitSegments(cmd); assert.equal(segs.length, 1, "multi-line quoted string should be one segment"); }); it("splits on delimiter AFTER closing quote", () => { assert.deepEqual( Guardrails.splitSegments('echo "hi" && git push'), ["echo \"hi\"", "git push"], ); }); it("handles nested quote types (single inside double)", () => { assert.deepEqual( Guardrails.splitSegments('echo "it\'s && banned"'), ['echo "it\'s && banned"'], ); }); it("handles nested quote types (double inside single)", () => { assert.deepEqual( Guardrails.splitSegments("echo 'he said \"hi\" && banned'"), ["echo 'he said \"hi\" && banned'"], ); }); }); // --------------------------------------------------------------------------- // stripQuotedStrings -- multi-line // --------------------------------------------------------------------------- describe("stripQuotedStrings -- multi-line", () => { it("strips double-quoted string spanning newlines", () => { assert.equal( Guardrails.stripQuotedStrings('echo "line1\nline2"'), "echo ", ); }); it("strips single-quoted string spanning newlines", () => { assert.equal( Guardrails.stripQuotedStrings("echo 'line1\nline2'"), "echo ", ); }); it("strips backtick-quoted string spanning newlines", () => { assert.equal( Guardrails.stripQuotedStrings("echo `line1\nline2`"), "echo ", ); }); }); // --------------------------------------------------------------------------- // splitSegments -- quote-aware (no split inside quotes) // --------------------------------------------------------------------------- describe("splitSegments -- quote-aware", () => { it("does NOT split multi-line node -e with double quotes", () => { const cmd = 'node -e "\nconsole.log(`git stash`)\n"'; const segs = Guardrails.splitSegments(cmd); assert.equal(segs.length, 1, "multi-line quoted string should be one segment"); }); it("does NOT split multi-line python3 -c with double quotes", () => { const cmd = 'python3 -c "\nimport subprocess\nsubprocess.run([\'git\', \'stash\'])\n"'; const segs = Guardrails.splitSegments(cmd); assert.equal(segs.length, 1, "multi-line python3 -c should be one segment"); }); it("does NOT split echo with escaped newline containing banned cmd", () => { const cmd = 'echo "line1\\ngit push"'; const segs = Guardrails.splitSegments(cmd); assert.equal(segs.length, 1, "escaped newline in quotes should not split"); }); it("allows heredoc body with banned phrase", () => { const cmd = "cat < { const cmd = "echo hi\ngit push"; const segs = Guardrails.splitSegments(cmd); assert.deepEqual(segs, ["echo hi", "git push"]); }); it("splits on real && outside quotes", () => { const cmd = 'git commit -m "a\\nb" && git push'; const segs = Guardrails.splitSegments(cmd); assert.equal(segs.length, 2); assert.equal(segs[1].trim(), "git push"); }); }); // --------------------------------------------------------------------------- // stripPrefixes -- helper function // --------------------------------------------------------------------------- describe("stripPrefixes", () => { it("strips VAR=value prefix", () => { assert.equal(Guardrails.stripPrefixes("FOO=bar echo hi"), "echo hi"); }); it("strips multiple VAR=value prefixes", () => { assert.equal(Guardrails.stripPrefixes("FOO=bar BAZ=qux echo hi"), "echo hi"); }); it("strips sudo", () => { assert.equal(Guardrails.stripPrefixes("sudo echo hi"), "echo hi"); }); it("strips command", () => { assert.equal(Guardrails.stripPrefixes("command echo hi"), "echo hi"); }); it("handles no prefix", () => { assert.equal(Guardrails.stripPrefixes("echo hi"), "echo hi"); }); it("strips env GIT_X=1 wrapper", () => { assert.equal(Guardrails.stripPrefixes("env GIT_X=1 git stash"), "git stash"); }); it("strips env -i wrapper", () => { assert.equal(Guardrails.stripPrefixes("env -i git add ."), "git add ."); }); it("strips env -u VAR wrapper", () => { assert.equal(Guardrails.stripPrefixes("env -u HOME git commit"), "git commit"); }); it("strips env -u VAR -i combination", () => { assert.equal(Guardrails.stripPrefixes("env -u VAR -i git stash"), "git stash"); }); it("strips env -i -u HOME -i combination (multiple flags)", () => { assert.equal(Guardrails.stripPrefixes("env -i -u HOME -i git add ."), "git add ."); }); it("strips FOO=bar env -i wrapper (VAR before env)", () => { assert.equal(Guardrails.stripPrefixes("FOO=bar env -i git stash"), "git stash"); }); it("strips env GIT_X=1 -i combination", () => { assert.equal(Guardrails.stripPrefixes("env GIT_X=1 -i git add ."), "git add ."); }); it("strips GIT_X=1 env VAR=val wrapper (interleaved)", () => { assert.equal(Guardrails.stripPrefixes("GIT_X=1 env git add ."), "git add ."); }); it("strips FOO=bar BAZ=qux env -i wrapper (multiple VARs before env)", () => { assert.equal(Guardrails.stripPrefixes("FOO=bar BAZ=qux env -i git stash"), "git stash"); }); it("strips env -i VAR=val git stash (VAR after flag)", () => { assert.equal(Guardrails.stripPrefixes("env -i VAR=val git stash"), "git stash"); }); it("strips env VAR1=val1 VAR2=val2 git add . (multiple VARs after env)", () => { assert.equal(Guardrails.stripPrefixes("env VAR1=val1 VAR2=val2 git add ."), "git add ."); }); it("preserves git -c options (not stripped)", () => { assert.equal(Guardrails.stripPrefixes("git -c user.name=x commit"), "git -c user.name=x commit"); }); it("strips env -i then preserves git -C ", () => { assert.equal(Guardrails.stripPrefixes("env -i git -C /worktree add ."), "git -C /worktree add ."); }); it("strips env then preserves env GIT_X=1 git -C ", () => { assert.equal(Guardrails.stripPrefixes("env GIT_X=1 git -C /worktree stash list"), "git -C /worktree stash list"); }); }); // --------------------------------------------------------------------------- // matchesBanned -- direct pattern matching tests // Each banned pattern: positive matches return non-null, negatives return null // --------------------------------------------------------------------------- describe("matchesBanned -- git add", () => { it("blocks git add -A", () => { assert.ok(Guardrails._checkBashBanned("git add -A")); }); it("blocks git add --all", () => { assert.ok(Guardrails._checkBashBanned("git add --all")); }); it("blocks git add .", () => { assert.ok(Guardrails._checkBashBanned("git add .")); }); it("allows git add src/a.py", () => { assert.equal(Guardrails._checkBashBanned("git add src/a.py"), null); }); }); describe("matchesBanned -- git commit", () => { it("blocks git commit -am", () => { assert.ok(Guardrails._checkBashBanned('git commit -am "msg"')); }); it("blocks git commit -a", () => { assert.ok(Guardrails._checkBashBanned("git commit -a -m msg")); }); it("blocks git commit --all", () => { assert.ok(Guardrails._checkBashBanned("git commit --all -m msg")); }); it("allows git commit --amend", () => { assert.equal(Guardrails._checkBashBanned("git commit --amend"), null); }); it('allows git commit -m "fix -a flag"', () => { assert.equal( Guardrails._checkBashBanned('git commit -m "fix -a flag"'), null, ); }); }); describe("matchesBanned -- git commit -a flags (R26)", () => { const wt = "/home/alee/Sources/6krrt-worktrees/agent-guardrails"; it("allows git -C diff --diff-filter=AM", () => { assert.equal( Guardrails._checkBashBanned(`git -C ${wt} diff --stat main..HEAD --diff-filter=AM`), null, ); }); it("allows git -C log --author=am --oneline", () => { assert.equal( Guardrails._checkBashBanned(`git -C ${wt} log --author=am --oneline`), null, ); }); it("allows git commit --amend -m x", () => { assert.equal( Guardrails._checkBashBanned("git commit --amend -m x"), null, ); }); it('allows git commit -m "am i ok"', () => { assert.equal( Guardrails._checkBashBanned('git commit -m "am i ok"'), null, ); }); it("allows node -e whose source mentions git commit -a", () => { assert.equal( Guardrails._checkBashBanned(`node -e 'console.log("git commit -a -m msg")'`), null, ); }); it('blocks git commit -am "x"', () => { assert.ok(Guardrails._checkBashBanned('git commit -am "x"')); }); it('blocks git commit -a -m "x"', () => { assert.ok(Guardrails._checkBashBanned('git commit -a -m "x"')); }); it('blocks git commit -m "x" -a', () => { assert.ok(Guardrails._checkBashBanned('git commit -m "x" -a')); }); it("blocks git commit --all -m x", () => { assert.ok(Guardrails._checkBashBanned("git commit --all -m x")); }); it("blocks git commit -qam x", () => { assert.ok(Guardrails._checkBashBanned("git commit -qam x")); }); it("blocks git -C commit -a -m x", () => { assert.ok(Guardrails._checkBashBanned(`git -C ${wt} commit -a -m x`)); }); it("blocks git -c k=v commit -a -m x", () => { assert.ok(Guardrails._checkBashBanned("git -c k=v commit -a -m x")); }); }); describe("matchesBanned -- git push/rebase/reset/merge", () => { it("blocks git push", () => { assert.ok(Guardrails._checkBashBanned("git push")); }); it("blocks git push origin main", () => { assert.ok(Guardrails._checkBashBanned("git push origin main")); }); it("allows git log --all", () => { assert.equal(Guardrails._checkBashBanned("git log --all"), null); }); it("blocks git rebase", () => { assert.ok(Guardrails._checkBashBanned("git rebase HEAD~1")); }); it("blocks git reset --soft", () => { assert.ok(Guardrails._checkBashBanned("git reset --soft HEAD~1")); }); it("blocks git merge --squash", () => { assert.ok(Guardrails._checkBashBanned("git merge --squash feature")); }); it("allows git merge", () => { assert.equal(Guardrails._checkBashBanned("git merge feature"), null); }); }); describe("matchesBanned -- gh pr create / tea pr create / tea pulls create", () => { it("blocks gh pr create", () => { assert.ok(Guardrails._checkBashBanned("gh pr create --title x")); }); it("blocks tea pr create", () => { assert.ok(Guardrails._checkBashBanned("tea pr create --title x")); }); it("blocks tea pulls create", () => { assert.ok(Guardrails._checkBashBanned("tea pulls create --title x")); }); it("allows gh pr view", () => { assert.equal(Guardrails._checkBashBanned("gh pr view 123"), null); }); }); describe("matchesBanned -- pkill / killall", () => { it("blocks pkill", () => { assert.ok(Guardrails._checkBashBanned("pkill -f x")); }); it("blocks cd /tmp && pkill x", () => { assert.ok(Guardrails._checkBashBanned("cd /tmp && pkill x")); }); it("blocks killall", () => { assert.ok(Guardrails._checkBashBanned("killall node")); }); it("allows grep pkill notes.md", () => { assert.equal(Guardrails._checkBashBanned("grep pkill notes.md"), null); }); }); describe("matchesBanned -- systemctl", () => { it("blocks systemctl --user stop llm-router", () => { assert.ok(Guardrails._checkBashBanned("systemctl --user stop llm-router")); }); it("blocks systemctl --user restart llm-router", () => { assert.ok(Guardrails._checkBashBanned("systemctl --user restart llm-router")); }); it("blocks systemctl --user kill llm-router", () => { assert.ok(Guardrails._checkBashBanned("systemctl --user kill llm-router")); }); it("allows systemctl status", () => { assert.equal(Guardrails._checkBashBanned("systemctl status llm-router"), null); }); }); describe("matchesBanned -- git worktree remove / git stash", () => { it("blocks git worktree remove", () => { assert.ok(Guardrails._checkBashBanned("git worktree remove /tmp/old")); }); it("blocks git stash", () => { assert.ok(Guardrails._checkBashBanned("git stash push -m msg")); }); it("allows git stash list", () => { assert.equal(Guardrails._checkBashBanned("git stash list"), null); }); it("allows git worktree list", () => { assert.equal(Guardrails._checkBashBanned("git worktree list"), null); }); }); // --------------------------------------------------------------------------- // checkBashBanned -- mode handling (hook-level integration) // --------------------------------------------------------------------------- describe("checkBashBanned -- block mode", () => { it("blocks echo ok; git push", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_bb_block", "bash", { command: "echo ok; git push" }, {}), { message: "bash/banned: blocked -- git push" }, ); }); it("blocks git add -A", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_bb_a", "bash", { command: "git add -A" }, {}), { message: "bash/banned: blocked -- git add -A/--all/." }, ); }); it("blocks git commit -am x", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_bb_am", "bash", { command: "git commit -am x" }, {}), { message: "bash/banned: blocked -- git commit -am" }, ); }); }); describe("checkBashBanned -- negative cases are allowed", () => { it("allows git add src/a.py (has pathspec, no -A/--all/.)", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bb_neg1", "bash", { command: "git add src/a.py" }, {}); }); it('allows git commit -m "fix -a flag" (quoted -a is stripped, not -am)', async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bb_neg2", "bash", { command: 'git commit -m "fix -a flag"' }, {}); }); it("allows git log --all (has git keyword but no banned pattern match)", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bb_neg3", "bash", { command: "git log --all" }, {}); }); it("allows grep pkill notes.md (pkill as argument, not command)", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bb_neg4", "bash", { command: "grep pkill notes.md" }, {}); }); it('allows git commit -m "then git push" (quoted content stripped)', async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bb_neg5", "bash", { command: 'git commit -m "then git push"' }, {}); }); }); describe("checkBashBanned -- warn mode", () => { it("allows and logs when mode is warn", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { bash_banned: "warn" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bb_warn", "bash", { command: "git push" }, {}); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const warnEntry = JSON.parse(lines[lines.length - 1]); assert.equal(warnEntry.rule, "bash_banned"); }); }); describe("checkBashBanned -- off mode", () => { it("allows silently when mode is off", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "off" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bb_off", "bash", { command: "git push" }, {}); }); }); describe("checkBashBanned -- sudo prefix stripping", () => { it("blocks sudo pkill (prefix stripped before check)", () => { assert.ok(Guardrails._checkBashBanned("sudo pkill -f x")); }); it("blocks sudo git push (prefix stripped before check)", () => { assert.ok(Guardrails._checkBashBanned("sudo git push")); }); }); describe("checkBashBanned -- quoted string handling in pipeline", () => { it("blocks echo ok; git push (first segment allowed, second blocked)", () => { assert.ok(Guardrails._checkBashBanned("echo ok; git push")); }); it('allows git commit -m "then git push" (quoted content stripped)', () => { assert.equal( Guardrails._checkBashBanned('git commit -m "then git push"'), null, ); }); // --- Defect r: quote-aware segment splitting --- it('allows multi-line node -e with quoted banned command (no split)', () => { // The multi-line command is ONE segment because the newline is inside quotes. // After stripping quotes, the segment becomes "node -e " which does not match any banned pattern. const cmd = 'node -e "\nconsole.log(`git stash`)\n"'; assert.equal(Guardrails._checkBashBanned(cmd), null); }); it('allows multi-line python3 -c with quoted banned command', () => { const cmd = 'python3 -c "\nimport subprocess\nsubprocess.run([\'git\', \'stash\'])\n"'; assert.equal(Guardrails._checkBashBanned(cmd), null); }); it('allows echo with escaped newline containing banned cmd', () => { // \n inside double quotes is literal text (not a real newline), // and the whole quoted string is stripped const cmd = 'echo "line1\\ngit push"'; assert.equal(Guardrails._checkBashBanned(cmd), null); }); it('allows heredoc body containing banned phrase', () => { // cat < { // Real newline outside quotes = two segments: "echo hi" (clean) and "git push" (banned) const cmd = "echo hi\ngit push"; assert.ok(Guardrails._checkBashBanned(cmd)); }); it('blocks git commit -m "a\\nb" && git push (real && outside quotes)', () => { // The && is outside the quotes, so it splits into two segments. // Second segment "git push" is banned. const cmd = 'git commit -m "a\\nb" && git push'; assert.ok(Guardrails._checkBashBanned(cmd)); }); }); // --------------------------------------------------------------------------- // checkBashProtectedPort -- direct function tests // --------------------------------------------------------------------------- describe("checkBashProtectedPort -- matches localhost ports", () => { it("matches localhost:8080", () => { const result = Guardrails._checkBashProtectedPort( "curl localhost:8080/health", [8080], ); assert.ok(result); assert.equal(result.port, 8080); }); it("matches 127.0.0.1:8080", () => { const result = Guardrails._checkBashProtectedPort( "wget http://127.0.0.1:8080/health", [8080], ); assert.ok(result); assert.equal(result.port, 8080); }); it("matches 0.0.0.0:8080", () => { const result = Guardrails._checkBashProtectedPort( "curl 0.0.0.0:8080/health", [8080], ); assert.ok(result); assert.equal(result.port, 8080); }); it("allows localhost:8081", () => { const result = Guardrails._checkBashProtectedPort( "curl localhost:8081/health", [8080], ); assert.equal(result, null); }); it("allows localhost:9090", () => { const result = Guardrails._checkBashProtectedPort( "curl localhost:9090/health", [8080], ); assert.equal(result, null); }); }); // --------------------------------------------------------------------------- // checkBashProtectedPort -- integration via hook // --------------------------------------------------------------------------- describe("checkBashProtectedPort -- block mode", () => { it("blocks curl localhost:8080/health", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_protected_port: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_bpp_block", "bash", { command: "curl localhost:8080/health" }, {}), { message: "bash/protected_port: blocked -- access to port 8080" }, ); }); it("allows curl localhost:8081/health", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_protected_port: "block" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bpp_allow", "bash", { command: "curl localhost:8081/health" }, {}); }); }); describe("checkBashProtectedPort -- warn mode", () => { it("allows and logs when mode is warn", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { bash_protected_port: "warn" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bpp_warn", "bash", { command: "curl localhost:8080/health" }, {}); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const warnEntry = JSON.parse(lines[lines.length - 1]); assert.equal(warnEntry.rule, "bash_protected_port"); }); }); describe("checkBashProtectedPort -- off mode", () => { it("allows silently when mode is off", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_protected_port: "off" } }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_bpp_off", "bash", { command: "curl localhost:8080/health" }, {}); }); }); // --------------------------------------------------------------------------- // bash_banned + bash_protected_port -- independent rule handling // --------------------------------------------------------------------------- describe("bash_banned + bash_protected_port -- independent rules", () => { it("bash_protected_port=warn allows curl 8080, bash_banned=block still blocks git push", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { bash_protected_port: "warn", bash_banned: "block" }, }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // Port check is warn => allowed + logged await callHook(hooks, "ses_bip_1", "bash", { command: "curl localhost:8080/health" }, {}); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const warnEntry = JSON.parse(lines[lines.length - 1]); assert.equal(warnEntry.rule, "bash_protected_port"); // Git push is still blocked by bash_banned await assert.rejects( callHook(hooks, "ses_bip_2", "bash", { command: "git push" }, {}), { message: "bash/banned: blocked -- git push" }, ); }); }); // --------------------------------------------------------------------------- // protected_ports from config changes the port // --------------------------------------------------------------------------- describe("protected_ports from config overrides default", () => { it("allows curl localhost:8080 when config port is 9090", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { bash_protected_port: "block" }, protected_ports: [9090], }); writeBoulder(dir, { status: "active", session_ids: [] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // Port 8080 is NOT protected (config uses 9090) await callHook(hooks, "ses_cp_override", "bash", { command: "curl localhost:8080/health" }, {}); // Port 9090 IS protected await assert.rejects( callHook(hooks, "ses_cp_block", "bash", { command: "curl localhost:9090/health" }, {}), { message: "bash/protected_port: blocked -- access to port 9090" }, ); }); }); // --------------------------------------------------------------------------- // loader contract -- new exports are functions // --------------------------------------------------------------------------- describe("new exports are functions", () => { it("stripQuotedStrings is a function", () => { assert.equal(typeof Guardrails.stripQuotedStrings, "function"); }); it("splitSegments is a function", () => { assert.equal(typeof Guardrails.splitSegments, "function"); }); it("stripPrefixes is a function", () => { assert.equal(typeof Guardrails.stripPrefixes, "function"); }); it("checkBashBanned is a function", () => { assert.equal(typeof Guardrails.checkBashBanned, "function"); }); it("checkBashProtectedPort is a function", () => { assert.equal(typeof Guardrails.checkBashProtectedPort, "function"); }); }); // --------------------------------------------------------------------------- // countTicked -- helper function tests // --------------------------------------------------------------------------- describe("countTicked", () => { it("returns 0 for empty string", () => { assert.equal(Guardrails.countTicked(""), 0); }); it("returns 0 for null", () => { assert.equal(Guardrails.countTicked(null), 0); }); it("returns 0 for undefined", () => { assert.equal(Guardrails.countTicked(undefined), 0); }); it("returns 0 for content with no tick marks", () => { assert.equal( Guardrails.countTicked("# Plan\n## TODOs\n- [ ] 1. do X"), 0, ); }); it("returns 1 for single tick", () => { assert.equal( Guardrails.countTicked("- [x] 1. done\n- [ ] 2. todo"), 1, ); }); it("returns correct count for multiple ticks", () => { assert.equal( Guardrails.countTicked("- [x] 1. a\n- [X] 2. b\n- [ ] 3. c"), 2, ); }); it("matches [x] and [X]", () => { assert.equal(Guardrails.countTicked("- [x] 1. x\n- [X] 2. X"), 2); }); it("only matches numbered ticks under TODOs format", () => { assert.equal( Guardrails.countTicked("- [x] 1. a\n- [x] 2. b\n- [x] 3. c\n- [x] 4. d\n- [x] 5. e"), 5, ); }); it("does not match incomplete tick lines", () => { assert.equal(Guardrails.countTicked("- [x] no number"), 0); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- exit 0 allows tick (edit) // --------------------------------------------------------------------------- describe("plan_tick_gate -- exit 0 allows tick (edit)", () => { it("allows edit that adds a tick when stub exits 0", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit0.sh"); writeFileSync(stubScript, "#!/bin/bash\nexit 0\n"); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_exit0", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- exit 0 allows tick (write) // --------------------------------------------------------------------------- describe("plan_tick_gate -- exit 0 allows tick (write)", () => { it("allows write that adds a tick when stub exits 0", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit0.sh"); writeFileSync(stubScript, "#!/bin/bash\nexit 0\n"); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_write0", "write", { filePath: planFile, content: "- [x] 1. done\n- [ ] 2. todo", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- exit 1 blocks tick with output (edit) // --------------------------------------------------------------------------- describe("plan_tick_gate -- exit 1 blocks tick (edit)", () => { it("blocks edit that adds a tick when stub exits 1", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit1.sh"); writeFileSync( stubScript, "#!/bin/bash\necho 'workdir is dirty'; exit 1\n", ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: ["ses_ptg_exit1"], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_ptg_exit1", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}), { message: /plan_tick_gate: verify_commit failed.*workdir is dirty.*Fix, commit, then tick again/ }, ); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- exit 1 blocks tick with output (write) // --------------------------------------------------------------------------- describe("plan_tick_gate -- exit 1 blocks tick (write)", () => { it("blocks write that adds a tick when stub exits 1", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit1.sh"); writeFileSync( stubScript, "#!/bin/bash\necho 'unpushed commits'; exit 1\n", ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: ["ses_ptg_write1"], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_ptg_write1", "write", { filePath: planFile, content: "- [x] 1. done", }, {}), { message: /plan_tick_gate: verify_commit failed.*unpushed commits.*Fix, commit, then tick again/ }, ); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- timeout blocks tick // --------------------------------------------------------------------------- describe("plan_tick_gate -- timeout blocks tick", () => { it("blocks when stub script sleeps past timeout_s", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_sleep.sh"); writeFileSync( stubScript, "#!/bin/bash\nsleep 100\n", ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 1, }, }); writeBoulder(dir, { status: "active", session_ids: ["ses_ptg_timeout"], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_ptg_timeout", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}), { message: /plan_tick_gate: verification timed out/ }, ); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- no tick increase => no gate // --------------------------------------------------------------------------- describe("plan_tick_gate -- no tick increase", () => { it("allows edit that removes a tick (delta <= 0)", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit0.sh"); writeFileSync(stubScript, "#!/bin/bash\nexit 0\n"); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); // Removing a tick: delta is 0, gate should not run await callHook(hooks, "ses_ptg_nodelta", "edit", { filePath: planFile, oldString: "- [x] 1. done", newString: "- [ ] 1. done", }, {}); }); it("allows edit that adds text but no tick (delta = 0)", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit0.sh"); writeFileSync(stubScript, "#!/bin/bash\nexit 0\n"); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_notick", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [ ] 1. do X with more detail", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- edit to other file is ignored // --------------------------------------------------------------------------- describe("plan_tick_gate -- other file is ignored", () => { it("does not gate edits to a non-plan file", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const otherFile = join(dir, "other.md"); const stubScript = join(dir, "stub_exit1.sh"); writeFileSync( stubScript, "#!/bin/bash\nexit 1\n", ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_other", "edit", { filePath: otherFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- missing script path blocks // --------------------------------------------------------------------------- describe("plan_tick_gate -- missing script", () => { it("blocks when script path does not exist", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const missingScript = join(dir, "nonexistent_verify.py"); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [missingScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: ["ses_ptg_missing"], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await assert.rejects( callHook(hooks, "ses_ptg_missing", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}), { message: /plan_tick_gate: script not found at .+nonexistent_verify\.py/ }, ); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- inactive boulder => no-op // --------------------------------------------------------------------------- describe("plan_tick_gate -- inactive boulder", () => { it("does nothing when boulder is inactive", async () => { const dir = newDir(); const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit1.sh"); writeFileSync( stubScript, "#!/bin/bash\nexit 1\n", ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "idle", session_ids: [], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_idle", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- no worktree_path => no-op // --------------------------------------------------------------------------- describe("plan_tick_gate -- no worktree_path", () => { it("does nothing when boulder lacks worktree_path", async () => { const dir = newDir(); const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit1.sh"); writeFileSync( stubScript, "#!/bin/bash\nexit 1\n", ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: [], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_nowt", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- off mode // --------------------------------------------------------------------------- describe("plan_tick_gate -- off mode", () => { it("allows tick silently when mode is off", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit1.sh"); writeFileSync( stubScript, "#!/bin/bash\nexit 1\n", ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "off" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_off", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- warn mode // --------------------------------------------------------------------------- describe("plan_tick_gate -- warn mode", () => { it("allows tick and logs when mode is warn", async () => { const dir = newDir(); const { cfgDir, logPath } = writeConfig(dir, { rules: { plan_tick_gate: "warn" }, tick_gate: { cmd: ["/nonexistent/verify.sh"], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: ["ses_ptg_warn"], worktree_path: dir, active_plan: join(dir, "plan.md"), }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_warn", "edit", { filePath: join(dir, "plan.md"), oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.trim().split("\n"); const warnEntry = JSON.parse(lines[lines.length - 1]); assert.equal(warnEntry.rule, "plan_tick_gate"); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- token substitution in cmd args // --------------------------------------------------------------------------- describe("plan_tick_gate -- token substitution", () => { it("passes substituted worktree to stub script", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_echo.sh"); writeFileSync( stubScript, '#!/bin/bash\necho "worktree=$1"\nexit 0\n', ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript, "{worktree}", "--check"], timeout_s: 5, }, }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_tokens", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- boulder absent => no-op // --------------------------------------------------------------------------- describe("plan_tick_gate -- no active boulder", () => { it("does nothing when boulder is absent", async () => { const dir = newDir(); const planFile = join(dir, "plan.md"); const stubScript = join(dir, "stub_exit1.sh"); writeFileSync( stubScript, "#!/bin/bash\nexit 1\n", ); chmodSync(stubScript, 0o755); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [stubScript], timeout_s: 5, }, }); const hooks = await Guardrails({ client: stubClient(async () => ({ data: {} })), directory: dir, }); await callHook(hooks, "ses_ptg_noboulder", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); }); }); // --------------------------------------------------------------------------- // plan_tick_gate -- config gate absent => no gate // --------------------------------------------------------------------------- describe("plan_tick_gate -- no gate config", () => { it("allows tick when tick_gate is absent from config", async () => { const dir = newDir(); const worktreePath = dir; const planFile = join(dir, "plan.md"); writeConfig(dir, { rules: { plan_tick_gate: "block" }, }); writeBoulder(dir, { status: "active", session_ids: [], worktree_path: worktreePath, active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir, }); await callHook(hooks, "ses_ptg_nogate", "edit", { filePath: planFile, oldString: "- [ ] 1. do X", newString: "- [x] 1. do X", }, {}); }); }); describe("Scope and Boulder Integration", () => { it("no-op when config absent", async () => { const dir = newDir(); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "bash", { command: "echo hi" }); }); it("allows when boulder is missing (B) rules fail-closed", async () => { const dir = newDir(); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); // No boulder => checkScope returns false => (B) rules skipped => call allowed await callHook(hooks, "ses_1", "write", { filePath: "main/a.py" }); }); it("allows (B) rules when boulder is active and session matches", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "write", { filePath: join(worktree, "a.py") }); }); it("allows when boulder is inactive (B) rules fail-closed", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "idle", worktree_path: worktree, session_ids: ["opencode:ses_1"] }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); // Inactive boulder => checkScope returns false => (B) rules skipped => call allowed await callHook(hooks, "ses_1", "write", { filePath: join(worktree, "a.py") }); }); it("allows when session is a descendant (parent walk)", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:parent_ses"] }); const client = stubClient(async ({ path }) => { if (path.id === "child_ses") return { data: { parentID: "opencode:parent_ses" } }; return { data: {} }; }); const hooks = await Guardrails({ client, directory: dir }); await callHook(hooks, "child_ses", "write", { filePath: join(worktree, "a.py") }); }); }); describe("bash_banned (Always-Scope)", () => { it("blocks regardless of boulder state", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); // No boulder written const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await assert.rejects( callHook(hooks, "ses_1", "bash", { command: "git push" }), { message: /bash\/banned: blocked -- git push/ } ); }); }); describe("R5 -- absent rules default to block (Design C)", () => { it("blocks git push with config {}", async () => { const dir = newDir(); writeConfig(dir, {}); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await assert.rejects( callHook(hooks, "ses_1", "bash", { command: "git push" }), { message: /bash\/banned: blocked -- git push/ } ); }); it("blocks dead dispatch with config {} and active boulder", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, {}); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await assert.rejects( callHook(hooks, "ses_1", "task", { prompt: "hello" }), { message: /task\/call_omo_agent needs category or subagent_type \(or task_id for resume\)/ } ); }); it("blocks both with config {rules:{}} and active boulder", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: {} }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); // dead dispatch => task_needs_agent defaults to block await assert.rejects( callHook(hooks, "ses_1", "task", { prompt: "hello" }), { message: /task\/call_omo_agent needs category or subagent_type \(or task_id for resume\)/ } ); // git push => bash_banned defaults to block (always-scope) await assert.rejects( callHook(hooks, "ses_1", "bash", { command: "git push" }), { message: /bash\/banned: blocked -- git push/ } ); }); // Task group -- task_needs_agent it("task_needs_agent: block mode rejects", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { task_needs_agent: "block" } }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await assert.rejects( callHook(hooks, "ses_1", "task", { prompt: "hello" }), { message: /task\/call_omo_agent needs category or subagent_type \(or task_id for resume\)/ } ); }); it("task_needs_agent: warn mode allows", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { task_needs_agent: "warn" } }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "task", { prompt: "hello" }); }); it("task_needs_agent: off mode allows", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { task_needs_agent: "off" } }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "task", { prompt: "hello" }); }); // Bash group -- bash_banned it("bash_banned: block mode rejects", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "block" } }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await assert.rejects( callHook(hooks, "ses_1", "bash", { command: "git push" }), { message: /bash\/banned: blocked -- git push/ } ); }); it("bash_banned: warn mode allows", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "warn" } }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "bash", { command: "git push" }); }); it("bash_banned: off mode allows", async () => { const dir = newDir(); writeConfig(dir, { rules: { bash_banned: "off" } }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "bash", { command: "git push" }); }); // Write group -- write_outside_worktree it("write_outside_worktree: block mode rejects", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { write_outside_worktree: "block" } }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await assert.rejects( callHook(hooks, "ses_1", "write", { filePath: join(dir, "main.py") }), { message: /write\/outside_worktree/ } ); }); it("write_outside_worktree: warn mode allows", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { write_outside_worktree: "warn" } }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "write", { filePath: join(dir, "main.py") }); }); it("write_outside_worktree: off mode allows", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); writeConfig(dir, { rules: { write_outside_worktree: "off" } }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "write", { filePath: join(dir, "main.py") }); }); // Tick gate group -- plan_tick_gate it("plan_tick_gate: off mode allows despite missing script", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); const planFile = join(worktree, "PLAN.md"); writeConfig(dir, { rules: { plan_tick_gate: "off" }, tick_gate: { cmd: [join(dir, ".omo", "nonexistent.sh")], timeout_s: 5 }, }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "write", { filePath: planFile, oldString: "- [ ] 1. do it", newString: "- [x] 1. do it", }); }); it("plan_tick_gate: block mode rejects with missing script", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); const planFile = join(worktree, "PLAN.md"); writeConfig(dir, { rules: { plan_tick_gate: "block" }, tick_gate: { cmd: [join(dir, ".omo", "nonexistent.sh")], timeout_s: 5 }, }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await assert.rejects( callHook(hooks, "ses_1", "write", { filePath: planFile, oldString: "- [ ] 1. do it", newString: "- [x] 1. do it", }), { message: /plan_tick_gate/ } ); }); it("plan_tick_gate: warn mode allows despite missing script", async () => { const dir = newDir(); const worktree = join(dir, "wt"); mkdirSync(worktree, { recursive: true }); const planFile = join(worktree, "PLAN.md"); writeConfig(dir, { rules: { plan_tick_gate: "warn" }, tick_gate: { cmd: [join(dir, ".omo", "nonexistent.sh")], timeout_s: 5 }, }); writeBoulder(dir, { status: "active", worktree_path: worktree, session_ids: ["opencode:ses_1"], active_plan: planFile, }); const hooks = await Guardrails({ client: stubClient(() => {}), directory: dir }); await callHook(hooks, "ses_1", "write", { filePath: planFile, oldString: "- [ ] 1. do it", newString: "- [x] 1. do it", }); }); });