Files
6krrt/deploy/opencode-plugin/guardrails.test.mjs

3323 lines
115 KiB
JavaScript

/**
* 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 <worktree> 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 <worktree> 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 <worktree> 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 > <main>/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 <<EOF\ngit stash\nEOF";
const segs = Guardrails.splitSegments(cmd);
assert.equal(segs.length, 1, "heredoc with banned phrase should be one segment");
});
it("splits on real newline outside quotes", () => {
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 <worktree>", () => {
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 <worktree>", () => {
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 <wt> diff --diff-filter=AM", () => {
assert.equal(
Guardrails._checkBashBanned(`git -C ${wt} diff --stat main..HEAD --diff-filter=AM`),
null,
);
});
it("allows git -C <wt> 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 <wt> 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 <<EOF...EOF is a single segment (no delimiters outside quotes)
const cmd = "cat <<EOF\ngit stash\nEOF";
assert.equal(Guardrails._checkBashBanned(cmd), null);
});
it('blocks echo hi\ngit push (real newline outside quotes)', () => {
// 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",
});
});
});