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