3323 lines
115 KiB
JavaScript
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",
|
|
});
|
|
});
|
|
});
|