Fix defect t: checkBashMainCheckout redirect exemption now uses join(directory, '.omo') matching checkWriteOutsideWorktree. Fix defect u: replay deletes its temp omoDir on exit via try/finally. Add R15 missing tests: - read-only --directory: verifies .omo files unchanged, no new files - one-call-per-rule: fixture triggers each rule, checks Always/(B) tables - per-session scope: two sessions with different worktrees don't block
469 lines
16 KiB
JavaScript
469 lines
16 KiB
JavaScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { execFileSync, execFile } from "node:child_process";
|
|
import { mkdtempSync } from "node:fs";
|
|
import os from "node:os";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname } from "node:path";
|
|
import http from "node:http";
|
|
import { promisify } from "node:util";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const PYTHON3 = execFileSync("which", ["python3"], { encoding: "utf-8" }).trim();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture mode tests (unchanged)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("guardrails-replay CLI", () => {
|
|
const FIXTURE_PATH = join(__dirname, "replay-fixture.json");
|
|
|
|
it("should summarize violations from fixture", async () => {
|
|
const dir = mkdtempSync(join(os.tmpdir(), "replay-test-"));
|
|
const wt = join(dir, "wt");
|
|
mkdirSync(wt, { recursive: true });
|
|
|
|
const output = execFileSync(
|
|
"node",
|
|
[join(__dirname, "guardrails-replay.mjs"), "--fixture", FIXTURE_PATH, "--assume-in-scope", wt],
|
|
{
|
|
cwd: dir,
|
|
encoding: "utf-8",
|
|
env: { ...process.env, NODE_PATH: __dirname },
|
|
},
|
|
);
|
|
|
|
assert.ok(output.includes("bash_banned"), "Should report bash_banned");
|
|
assert.ok(output.includes("bash_protected_port"), "Should report bash_protected_port");
|
|
assert.ok(output.includes("task_banned_agent"), "Should report task_banned_agent");
|
|
});
|
|
|
|
it("should correctly identify rewrites for task_worktree_line", async () => {
|
|
const dir = mkdtempSync(join(os.tmpdir(), "replay-test-"));
|
|
const wt = join(dir, "wt");
|
|
mkdirSync(wt, { recursive: true });
|
|
|
|
const output = execFileSync(
|
|
"node",
|
|
[join(__dirname, "guardrails-replay.mjs"), "--fixture", FIXTURE_PATH, "--assume-in-scope", wt],
|
|
{
|
|
cwd: dir,
|
|
encoding: "utf-8",
|
|
env: { ...process.env, NODE_PATH: __dirname },
|
|
},
|
|
);
|
|
|
|
assert.ok(output.includes("task_worktree_line"), "Should report task_worktree_line");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL mode tests (local node:http server)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("guardrails-replay CLI — URL mode", () => {
|
|
/**
|
|
* Build a local HTTP server that responds with the opencode API endpoint shapes.
|
|
*/
|
|
function createServer(fixture) {
|
|
return new Promise((resolve, reject) => {
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
|
|
if (url.pathname === "/session" && req.method === "GET") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(fixture.sessions));
|
|
return;
|
|
}
|
|
|
|
const messageMatch = url.pathname.match(/^\/session\/([^/]+)\/message$/);
|
|
if (messageMatch && req.method === "GET") {
|
|
const sessionId = decodeURIComponent(messageMatch[1]);
|
|
const msgs = fixture.messagesBySession[sessionId] || [];
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(msgs));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end("not found");
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const addr = server.address();
|
|
resolve({ server, url: `http://127.0.0.1:${addr.port}` });
|
|
});
|
|
server.on("error", reject);
|
|
});
|
|
}
|
|
|
|
it("should replay tool calls from URL endpoints", async () => {
|
|
const dir = mkdtempSync(join(os.tmpdir(), "replay-test-url-"));
|
|
const wt = join(dir, "wt");
|
|
mkdirSync(wt, { recursive: true });
|
|
|
|
const fixture = {
|
|
sessions: [
|
|
{ id: "ses_url_001", directory: wt, updatedAt: "2026-01-01T00:00:00Z" },
|
|
{ id: "ses_url_002", directory: wt, updatedAt: "2026-01-01T00:01:00Z" },
|
|
],
|
|
messagesBySession: {
|
|
"ses_url_001": [
|
|
{
|
|
parts: [
|
|
{
|
|
type: "tool",
|
|
tool: "task",
|
|
callID: "call_1",
|
|
state: {
|
|
status: "completed",
|
|
input: { prompt: "do X", category: "quick", subagent_type: "oh-my-claudecode:oracle" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
"ses_url_002": [
|
|
{
|
|
parts: [
|
|
{
|
|
type: "tool",
|
|
tool: "bash",
|
|
callID: "call_2",
|
|
state: {
|
|
status: "completed",
|
|
input: { command: "git push origin main" },
|
|
},
|
|
},
|
|
{
|
|
type: "tool",
|
|
tool: "bash",
|
|
callID: "call_3",
|
|
state: {
|
|
status: "completed",
|
|
input: { command: "curl localhost:8080/health" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const { server, url } = await createServer(fixture);
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync(
|
|
"node",
|
|
[
|
|
join(__dirname, "guardrails-replay.mjs"),
|
|
"--url", url,
|
|
"--directory", dir,
|
|
"--assume-in-scope", wt,
|
|
"--limit", "10",
|
|
],
|
|
{
|
|
cwd: __dirname,
|
|
timeout: 15000,
|
|
},
|
|
);
|
|
|
|
// Verify per-rule counts
|
|
assert.ok(stdout.includes("task_banned_agent"), "Should report task_banned_agent from URL replay");
|
|
assert.ok(stdout.includes("bash_banned"), "Should report bash_banned from URL replay");
|
|
assert.ok(stdout.includes("bash_protected_port"), "Should report bash_protected_port from URL replay");
|
|
|
|
// Verify counts match fixture expectations
|
|
const taskBanLine = stdout.split("\n").find((l) => l.includes("task_banned_agent"));
|
|
assert.ok(taskBanLine.includes("1"), `task_banned_agent should have 1 block, got: ${taskBanLine}`);
|
|
|
|
const bashBanLine = stdout.split("\n").find((l) => l.includes("bash_banned"));
|
|
assert.ok(bashBanLine.includes("1"), `bash_banned should have 1 block, got: ${bashBanLine}`);
|
|
|
|
const portLine = stdout.split("\n").find((l) => l.includes("bash_protected_port"));
|
|
assert.ok(portLine.includes("1"), `bash_protected_port should have 1 block, got: ${portLine}`);
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
it("should handle empty sessions from URL", async () => {
|
|
const dir = mkdtempSync(join(os.tmpdir(), "replay-test-url-empty-"));
|
|
|
|
const server = http.createServer((req, res) => {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end("[]");
|
|
});
|
|
|
|
await new Promise((resolve) => {
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync(
|
|
"node",
|
|
[
|
|
join(__dirname, "guardrails-replay.mjs"),
|
|
"--url", `http://127.0.0.1:${server.address().port}`,
|
|
"--directory", dir,
|
|
],
|
|
{
|
|
cwd: __dirname,
|
|
timeout: 15000,
|
|
},
|
|
);
|
|
|
|
// Should succeed with output containing the table headers
|
|
assert.ok(stdout.includes("Rule"));
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
it("should use --directory for the config file path", async () => {
|
|
const dir = mkdtempSync(join(os.tmpdir(), "replay-test-dir-"));
|
|
const wt = join(dir, "wt");
|
|
mkdirSync(wt, { recursive: true });
|
|
|
|
// Verify the fixture-mode uses the specified directory
|
|
const output = execFileSync(
|
|
"node",
|
|
[
|
|
join(__dirname, "guardrails-replay.mjs"),
|
|
"--fixture", join(__dirname, "replay-fixture.json"),
|
|
"--directory", dir,
|
|
"--assume-in-scope", wt,
|
|
],
|
|
{
|
|
cwd: __dirname,
|
|
encoding: "utf-8",
|
|
},
|
|
);
|
|
|
|
assert.ok(output.includes("bash_banned"), "Should work with --directory");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// R19 — defect t (redirect exemption) + defect u (temp dir cleanup) + R15 tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("guardrails-replay R19 tests", () => {
|
|
const R19_FIXTURE = join(__dirname, "replay-fixture-r19.json");
|
|
|
|
it("defect t: redirect exemption in checkBashMainCheckout uses .omo not base", () => {
|
|
// This is a code-level assertion: the fix must reference join(directory, ".omo")
|
|
// not `base`. We verify by reading the source.
|
|
const src = readFileSync(join(__dirname, "guardrails.js"), "utf-8");
|
|
const redirectExempt = src.match(/!_isInside\(target,\s*join\(directory,\s*".omo"\)\)/);
|
|
assert.ok(redirectExempt, "Redirect exemption should use join(directory, '.omo') not base");
|
|
});
|
|
|
|
it("defect u: replay temp omoDir is deleted after script completes", async () => {
|
|
const dir = mkdtempSync(join(os.tmpdir(), "replay-test-cleanup-"));
|
|
const wt = join(dir, "wt");
|
|
mkdirSync(wt, { recursive: true });
|
|
|
|
const output = execFileSync(
|
|
"node",
|
|
[
|
|
join(__dirname, "guardrails-replay.mjs"),
|
|
"--fixture", R19_FIXTURE,
|
|
"--directory", dir,
|
|
"--assume-in-scope", wt,
|
|
],
|
|
{
|
|
cwd: __dirname,
|
|
encoding: "utf-8",
|
|
},
|
|
);
|
|
|
|
assert.ok(output.includes("bash_banned"), "Should report bash_banned");
|
|
|
|
// Verify NO guardrails-replay-* dirs remain in /tmp
|
|
const tmpFiles = readdirSync(os.tmpdir());
|
|
const leftover = tmpFiles.filter((f) => f.startsWith("guardrails-replay-"));
|
|
assert.equal(leftover.length, 0, `Temp omoDir should be cleaned up, but found: ${leftover}`);
|
|
});
|
|
|
|
it("R15 #1: --directory is read-only (omo files unchanged, no new files)", () => {
|
|
const dir = mkdtempSync(join(os.tmpdir(), "replay-test-readonly-"));
|
|
const omoDir = join(dir, ".omo");
|
|
mkdirSync(omoDir, { recursive: true });
|
|
|
|
const knownBoulder = JSON.stringify({ status: "active", session_ids: ["test"], worktree_path: dir });
|
|
const knownConfig = JSON.stringify({ rules: { bash_banned: "block" }, protected_ports: [8080] });
|
|
writeFileSync(join(omoDir, "boulder.json"), knownBoulder);
|
|
writeFileSync(join(omoDir, "guardrails.json"), knownConfig);
|
|
|
|
// Record original content for byte-identity check
|
|
const originalBoulder = readFileSync(join(omoDir, "boulder.json"));
|
|
const originalConfig = readFileSync(join(omoDir, "guardrails.json"));
|
|
|
|
const wt = join(dir, "wt");
|
|
mkdirSync(wt, { recursive: true });
|
|
|
|
// Record existing structure before replay (includes .omo/ and wt/)
|
|
const existingFiles = new Set(readdirSync(dir, { recursive: true }));
|
|
|
|
const output = execFileSync(
|
|
"node",
|
|
[
|
|
join(__dirname, "guardrails-replay.mjs"),
|
|
"--fixture", join(__dirname, "replay-fixture.json"),
|
|
"--directory", dir,
|
|
"--assume-in-scope", wt,
|
|
],
|
|
{
|
|
cwd: __dirname,
|
|
encoding: "utf-8",
|
|
},
|
|
);
|
|
|
|
// .omo files must be byte-identical to originals
|
|
assert.strictEqual(
|
|
readFileSync(join(omoDir, "boulder.json")).toString(),
|
|
originalBoulder.toString(),
|
|
"boulder.json must be byte-identical",
|
|
);
|
|
assert.strictEqual(
|
|
readFileSync(join(omoDir, "guardrails.json")).toString(),
|
|
originalConfig.toString(),
|
|
"guardrails.json must be byte-identical",
|
|
);
|
|
|
|
// No new files were created under the directory by the replay
|
|
const afterFiles = readdirSync(dir, { recursive: true });
|
|
for (const f of afterFiles) {
|
|
assert.ok(existingFiles.has(f), `No new files: ${f} was not present before replay`);
|
|
}
|
|
});
|
|
|
|
it("R15 #2: one call per rule id lands in the right table", () => {
|
|
// Use a fixed directory so the fixture paths are predictable.
|
|
const fixedDir = "/tmp/r19-test-rules-wt";
|
|
mkdirSync(fixedDir, { recursive: true });
|
|
const wt = join(fixedDir, "wt");
|
|
mkdirSync(wt, { recursive: true });
|
|
|
|
try {
|
|
const output = execFileSync(
|
|
"node",
|
|
[
|
|
join(__dirname, "guardrails-replay.mjs"),
|
|
"--fixture", R19_FIXTURE,
|
|
"--directory", fixedDir,
|
|
"--assume-in-scope", wt,
|
|
],
|
|
{
|
|
cwd: __dirname,
|
|
encoding: "utf-8",
|
|
},
|
|
);
|
|
|
|
// Always table: bash_banned + bash_protected_port
|
|
const alwaysSection = output.split("Always-Scope Rules")[1] || "";
|
|
const firstSectionEnd = alwaysSection.indexOf("(B)");
|
|
const alwaysBlock = firstSectionEnd > 0 ? alwaysSection.slice(0, firstSectionEnd) : alwaysSection;
|
|
assert.ok(alwaysBlock.includes("bash_banned"), "Always table should contain bash_banned");
|
|
assert.ok(alwaysBlock.includes("bash_protected_port"), "Always table should contain bash_protected_port");
|
|
|
|
// (B) table: scoped rules that fire from the fixture.
|
|
// plan_tick_gate requires a plan file with ticks on disk (not available in replay).
|
|
const scopedSection = output.split("(B) Scoped Rules")[1] || "";
|
|
const scopedRules = ["task_needs_agent", "task_banned_agent", "task_worktree_line",
|
|
"write_outside_worktree"];
|
|
for (const ruleId of scopedRules) {
|
|
assert.ok(scopedSection.includes(ruleId), `(B) table should contain ${ruleId}`);
|
|
}
|
|
} finally {
|
|
rmSync(fixedDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("R15 #3: per-session scope — two sessions, different worktrees", async () => {
|
|
const wtA = mkdtempSync(join(os.tmpdir(), "replay-wtA-"));
|
|
const wtB = mkdtempSync(join(os.tmpdir(), "replay-wtB-"));
|
|
|
|
// Two sessions: each with a WORKTREE line naming its own worktree
|
|
// Both in-scope via --assume-in-scope
|
|
const fixture = [
|
|
{
|
|
sessionID: "ses_scope_a",
|
|
callID: "call_a_1",
|
|
tool: "task",
|
|
args: {
|
|
category: "quick",
|
|
prompt: `WORKTREE: ${wtA}. cd there first; never edit under /home/alee/Sources/6krrt/.\\nAnalyze.`,
|
|
},
|
|
},
|
|
{
|
|
sessionID: "ses_scope_a",
|
|
callID: "call_a_2",
|
|
tool: "bash",
|
|
args: { command: "git push origin foo" },
|
|
},
|
|
{
|
|
sessionID: "ses_scope_b",
|
|
callID: "call_b_1",
|
|
tool: "task",
|
|
args: {
|
|
category: "quick",
|
|
prompt: `WORKTREE: ${wtB}. cd there first; never edit under /home/alee/Sources/6krrt/.\\nAnalyze.`,
|
|
},
|
|
},
|
|
{
|
|
sessionID: "ses_scope_b",
|
|
callID: "call_b_2",
|
|
tool: "bash",
|
|
args: { command: "git push origin bar" },
|
|
},
|
|
];
|
|
|
|
const fixturePath = join(os.tmpdir(), "replay-fixture-scope.json");
|
|
writeFileSync(fixturePath, JSON.stringify(fixture));
|
|
|
|
try {
|
|
const dir = mkdtempSync(join(os.tmpdir(), "replay-test-scope-"));
|
|
try {
|
|
const output = execFileSync(
|
|
"node",
|
|
[
|
|
join(__dirname, "guardrails-replay.mjs"),
|
|
"--fixture", fixturePath,
|
|
"--directory", dir,
|
|
"--assume-in-scope", wtA,
|
|
],
|
|
{
|
|
cwd: __dirname,
|
|
encoding: "utf-8",
|
|
},
|
|
);
|
|
|
|
// Neither session's bash calls should be blocked — both are in-scope
|
|
// (assume-in-scope makes all calls in-scope for bash_banned check)
|
|
const bashBanLine = output.split("\n").find((l) => l.includes("bash_banned"));
|
|
if (bashBanLine) {
|
|
const match = bashBanLine.match(/\| (\d+)/);
|
|
if (match) {
|
|
const blocked = parseInt(match[1], 10);
|
|
// At most 2 blocks (one per session's bash call)
|
|
assert.ok(blocked <= 2, `Expected at most 2 blocks, got ${blocked}`);
|
|
}
|
|
}
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
} finally {
|
|
rmSync(wtA, { recursive: true, force: true });
|
|
rmSync(wtB, { recursive: true, force: true });
|
|
try { rmSync(fixturePath, { force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
});
|