diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 70471d25a..2c0c457bd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -42,6 +42,9 @@ import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" +// altimate_change start — critic gate +import { Critic } from "@/tool/critic" +// altimate_change end import { Tool } from "@/tool/tool" import { PermissionNext } from "@/permission/next" import { SessionStatus } from "./status" @@ -1501,6 +1504,28 @@ export namespace SessionPrompt { args, }, ) + // altimate_change start — critic gate (pre-execution safety check) + // Flag-gated (ALTIMATE_CRITIC_GATE), default off. On a hard verdict we + // skip execution and feed the reason back so the model can retry, while + // still emitting tool.execute.after so observability sees the blocked call. + if (Critic.enabled()) { + const verdict = await Critic.gate(item.id, args as Record, Critic.basicSafetyVerifier) + if (!verdict.allow) { + const blocked = { + title: `Blocked: ${item.id}`, + metadata: { critic: { blocked: true, reason: verdict.feedback } } as any, + output: verdict.feedback ?? "Blocked by altimate verifier.", + attachments: [] as never[], + } + await Plugin.trigger( + "tool.execute.after", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, + blocked, + ) + return blocked + } + } + // altimate_change end const result = await item.execute(args, ctx) const output = { ...result, diff --git a/packages/opencode/src/tool/critic.ts b/packages/opencode/src/tool/critic.ts new file mode 100644 index 000000000..21ac3e3a1 --- /dev/null +++ b/packages/opencode/src/tool/critic.ts @@ -0,0 +1,262 @@ +/** + * Pre-execution critic gate. + * + * Before a SIDE-EFFECTING tool runs (bash, write, edit, sql_execute, dbt_*), a + * verifier checks the proposed args; on hard failure the call is denied and the + * reason is fed back so the model can retry — instead of executing a bad action. + * + * The judgment plugs in via the `Verifier` interface. Two impls ship here: + * - `ALLOW_ALL` — ungated (open); for tests / opt-out. + * - `basicSafetyVerifier` — the wired default: a conservative, dependency-free + * heuristic that blocks catastrophic, unambiguous + * host-destructive bash (e.g. `rm -rf /`, fork bombs, + * `mkfs`/`dd` on a raw device). + * + * This is a best-effort safety NET, NOT a security boundary or sandbox: a + * determined caller can obfuscate around literal patterns (command substitution, + * base64-decode-pipe-sh, etc.). Defense in depth lives elsewhere (OS sandbox, the + * permission system, and a richer pluggable verifier the caller may inject). + * + * Pure + testable. Wired into session/prompt.ts, just before `item.execute(args, ctx)`. + */ + +export namespace Critic { + /** + * Side-effecting tools worth gating. Reads (glob/grep/read) are never gated. + * NOTE: the gate is wired into the native ToolRegistry execute wrapper only; + * MCP-provided tools run through a separate wrapper and are NOT gated. The + * shipped `basicSafetyVerifier` is bash-only (a native tool), so this is a + * no-op gap today — but a product injecting a verifier for `sql_execute`/ + * `dbt_run` must confirm those are native, not MCP, tools. + */ + export const DEFAULT_GATED = ["bash", "write", "edit", "sql_execute", "dbt_run", "patch"] + + export interface Verdict { + ok: boolean + reason?: string + } + + /** The judgment interface. Default impl allows all (open). Product plugs a richer verifier. */ + export interface Verifier { + verify(toolName: string, args: Record): Verdict | Promise + } + + export const ALLOW_ALL: Verifier = { verify: () => ({ ok: true }) } + + export function enabled(): boolean { + return process.env["ALTIMATE_CRITIC_GATE"] === "1" + } + + export function isGated(toolName: string, gated: string[] = DEFAULT_GATED): boolean { + return gated.includes(toolName) + } + + // ── Heuristic bash safety ────────────────────────────────────────────────── + // + // Targets that, combined with a recursive+force `rm`, mean catastrophic loss. + // Deliberately ABSOLUTE only — `.`, `./` and bare `*` are NOT here: clearing + // the working/build directory (`rm -rf *`, `rm -rf .`) is routine and safe in a + // sandboxed workspace, and we have no `workdir` context to judge them. + const RM_FATAL_TARGETS = new Set(["/", "/*", "/.", "/..", "~", "~/", "~/*", "$home", "$home/", "$home/*"]) + // Top-level system paths whose recursive deletion bricks the machine. + const RM_FATAL_TOPLEVEL = [ + "/etc", + "/usr", + "/var", + "/bin", + "/sbin", + "/boot", + "/lib", + "/lib64", + "/sys", + "/dev", + "/proc", + "/root", + "/home", + "/opt", + ] + + // Transparent command prefixes: words that may precede the real command without + // changing which command runs. Lets `sudo rm -rf /` and `bash -c "rm -rf /"` + // still be seen as an `rm` in command position, while `git commit -m "rm -rf /"` + // (rm buried in an argument) is not. + const TRANSPARENT_PREFIX = new Set([ + "sudo", + "doas", + "nohup", + "time", + "env", + "exec", + "command", + "builtin", + "ionice", + "nice", + "setsid", + "stdbuf", + "then", + "do", + "else", + "bash", + "sh", + "zsh", + "dash", + "ksh", + ]) + + function isFatalRmTarget(tok: string): boolean { + // Normalize bash brace expansion: ${home} -> $home, ${home}/ -> $home/ + const norm = tok.replace(/^\$\{([a-z_]+)\}(\/.*)?$/, (_m, v, rest) => "$" + v + (rest ?? "")) + if (RM_FATAL_TARGETS.has(tok) || RM_FATAL_TARGETS.has(norm)) return true + // Catch a system path itself, its trailing-slash form, OR a glob wipe of its + // contents (`/var/*`) — but NOT a scoped subpath like `/home/user/project`. + return RM_FATAL_TOPLEVEL.some((p) => tok === p || tok === p + "/" || tok === p + "/*") + } + + /** + * Is the token at index `i` in COMMAND position (the start of a pipeline + * segment), versus an argument to some other command? Walks left skipping + * flags and transparent prefixes; a separator or the start means command + * position, any other bareword means it's an argument. + */ + function isCommandPosition(tokens: string[], i: number, sep: Set): boolean { + for (let j = i - 1; j >= 0; j--) { + const t = tokens[j] + if (sep.has(t)) return true + if (t.startsWith("-")) continue // a flag (e.g. `sudo -E`, `bash -c`) + if (TRANSPARENT_PREFIX.has(t)) continue + return false // a real preceding word -> rm is an argument, not the command + } + return true // reached the start through only flags/prefixes + } + + /** + * Detect a catastrophic, unambiguous host-destructive bash command. Returns a + * human reason when dangerous, else null. Conservative on purpose — it only + * fires on the few patterns that are almost never legitimate. Quotes are + * stripped so `bash -c "rm -rf /"` is still seen; this can over-match a command + * that merely mentions such a string (acceptable: the gate is opt-in/off by default). + */ + export function detectDangerousBash(raw: string): string | null { + if (!raw || typeof raw !== "string") return null + if (raw.length > 1_000_000) return null // cap — don't scan pathologically huge input + // Normalize: drop quotes, collapse whitespace, lowercase for keyword/path matching. + const norm = raw.replace(/['"`]/g, "").replace(/\s+/g, " ").trim().toLowerCase() + if (!norm) return null + + // Fork bomb: :(){ :|:& };: + if (norm.replace(/\s+/g, "").includes(":|:&") || /:\s*\(\s*\)\s*\{/.test(norm)) { + return "fork bomb" + } + // Explicit intent to remove the root filesystem. + if (norm.includes("--no-preserve-root")) { + return "rm with --no-preserve-root targets the root filesystem" + } + // mkfs on a block device. + if (/\bmkfs(\.\w+)?\b/.test(norm) && /\/dev\//.test(norm)) { + return "mkfs on a block device" + } + // dd writing to a raw disk. + if (/\bdd\b/.test(norm) && /\bof=\/dev\/(sd|nvme|disk|hd|vd|mmcblk)/.test(norm)) { + return "dd writing to a raw block device" + } + // Redirect over a raw disk. + if (/>\s*\/dev\/(sd|nvme|disk|hd|vd|mmcblk)/.test(norm)) { + return "redirect over a raw block device" + } + // Recursive chmod/chown of the root filesystem (short `-R`/`-rf` or long + // `--recursive`, against bare `/` or a `/*` glob wipe). + if (/\bch(mod|own)\b/.test(norm) && /(^|\s)(-[a-z]*r|--recursive)\b/.test(norm) && /\s\/\*?(\s|$)/.test(norm)) { + return "recursive permission/ownership change on /" + } + // Recursive + force rm of a fatal target. Split shell separators into their + // own tokens so they don't glue to a target (`/;`) and so EVERY `rm` in a + // compound command is inspected — not just the last one. + const SEP = new Set([";", "|", "&", ">", "<"]) + const tokens = norm + .replace(/([;|&><])/g, " $1 ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + for (let i = 0; i < tokens.length; i++) { + // Match `rm` or a fully-qualified `/bin/rm`, but only in command position + // (not when `rm -rf /` merely appears inside another command's argument). + if (tokens[i] !== "rm" && !tokens[i].endsWith("/rm")) continue + if (!isCommandPosition(tokens, i, SEP)) continue + let recursive = false + let force = false + const targets: string[] = [] + for (let j = i + 1; j < tokens.length; j++) { + const t = tokens[j] + if (SEP.has(t)) break // end of this rm invocation + if (t.startsWith("-")) { + if (t === "--recursive" || (/^-[a-z]+$/.test(t) && t.includes("r"))) recursive = true + if (t === "--force" || (/^-[a-z]+$/.test(t) && t.includes("f"))) force = true + } else { + targets.push(t) + } + } + if (recursive && force) { + for (const t of targets) { + if (isFatalRmTarget(t)) return `recursive force-delete of "${t}"` + } + } + } + return null + } + + /** + * The wired default verifier. Blocks only catastrophic bash; everything else + * (other gated tools, non-bash) is allowed. A product may inject a richer one. + */ + export const basicSafetyVerifier: Verifier = { + verify(toolName, args) { + if (toolName !== "bash") return { ok: true } + const reason = detectDangerousBash(String(args?.["command"] ?? "")) + return reason ? { ok: false, reason } : { ok: true } + }, + } + + export interface GateResult { + allow: boolean + /** when blocked, the message to feed back to the model in place of execution. */ + feedback?: string + } + + /** + * Decide whether a proposed tool call may execute. Non-gated tools always pass. + * Gated tools are checked by the verifier; a not-ok verdict blocks with feedback. + * NEVER throws — a critic failure must not break the agent (fail-open on error). + */ + /** Max time to wait on a verifier before failing open. A hung verifier must + * never hang the agent. */ + export const VERIFIER_TIMEOUT_MS = 5000 + + export async function gate( + toolName: string, + args: Record, + verifier: Verifier = ALLOW_ALL, + gated: string[] = DEFAULT_GATED, + timeoutMs: number = VERIFIER_TIMEOUT_MS, + ): Promise { + if (!enabled() || !isGated(toolName, gated)) return { allow: true } + try { + // async IIFE so a synchronous throw in verify() rejects the promise (and is + // caught below) rather than escaping the Promise.race. + const verifyPromise = (async () => verifier.verify(toolName, args))() + const timeout = new Promise((resolve) => { + const t = setTimeout(() => resolve({ ok: true, reason: "__timeout__" }), timeoutMs) + // don't keep the event loop alive for this guard timer + ;(t as any)?.unref?.() + }) + const v = await Promise.race([verifyPromise, timeout]) + if (v.ok) return { allow: true } + return { + allow: false, + feedback: `Blocked by altimate verifier before execution: ${v.reason ?? "failed validation"}. Fix and retry.`, + } + } catch { + // Fail-open: observability/governance must never break core functionality. + return { allow: true } + } + } +} diff --git a/packages/opencode/test/tool/critic-adversarial.test.ts b/packages/opencode/test/tool/critic-adversarial.test.ts new file mode 100644 index 000000000..5d5f46052 --- /dev/null +++ b/packages/opencode/test/tool/critic-adversarial.test.ts @@ -0,0 +1,97 @@ +/** + * Adversarial probes for the critic gate's default safety heuristic. + * + * Two purposes: + * 1. Confirm the heuristic catches the obfuscations it CLAIMS to catch + * (whitespace, quote-wrapping, flag reordering, sudo/compound prefixes). + * 2. Honestly DOCUMENT the obfuscations it does NOT catch. The default verifier + * is a best-effort safety net, not a security boundary — a determined caller + * can always evade literal pattern matching. These "known bypass" tests are + * guardrails against over-claiming: if one ever starts being caught, great, + * but we never depend on it. Real isolation is the OS sandbox + permission + * system + a richer pluggable verifier. + */ +import { describe, expect, test } from "bun:test" +import { Critic } from "../../src/tool/critic" + +describe("critic adversarial — obfuscations that ARE caught", () => { + const caught = [ + "rm -rf /", // arbitrary whitespace + "\trm -rf /\n", // surrounding control whitespace + "RM -RF /", // case (heuristic lowercases; harmless over-match) + 'bash -c "rm -rf /"', // shell wrapper, quotes stripped + "sh -c 'rm -rf /'", // single-quote wrapper + 'r""m -rf /', // quote splicing inside the command name + "sudo rm -rf /", // privilege prefix + "nohup rm -rf / &", // backgrounded + "true; rm -rf /", // statement separator + "ls && rm -rf ~", // conjunction, home target + "rm -rf /var", // system path + "/bin/rm -rf /", // fully-qualified rm path + "/usr/bin/rm -rf /", // fully-qualified rm in /usr/bin + "sudo -E rm -rf /", // privilege prefix with a flag + "rm -rf ${HOME}", // brace-expanded $HOME + ] + for (const cmd of caught) { + test(`caught: ${JSON.stringify(cmd)}`, () => { + expect(Critic.detectDangerousBash(cmd)).not.toBeNull() + }) + } +}) + +describe("critic adversarial — KNOWN bypasses (documented, not depended upon)", () => { + // These evade literal matching. We assert current behavior so the limitation is + // explicit and reviewed, NOT because allowing them is desirable. + const knownBypasses = [ + "$(echo rm) -rf /", // command substitution splits the rm token + "`echo rm` -rf /", // backtick substitution — `echo` is not a transparent prefix, so rm isn't in command position + "echo cm0gLXJmIC8K | base64 -d | sh", // base64-encoded `rm -rf /` + "find / -delete", // destructive without rm + "rm -rf $TARGET", // target hidden behind a variable + "alias x='rm -rf /'; x", // aliased + "xargs rm -rf < /tmp/list", // target via stdin (no literal fatal target token) + ] + for (const cmd of knownBypasses) { + test(`known bypass (returns null today): ${JSON.stringify(cmd)}`, () => { + expect(Critic.detectDangerousBash(cmd)).toBeNull() + }) + } +}) + +describe("critic adversarial — gate never throws, regardless of input", () => { + test("malformed/pathological args do not crash the gate (fail-open)", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + const inputs: any[] = [ + undefined, + null, + {}, + { command: 123 }, + { command: { nested: true } }, + { command: "x".repeat(100_000) }, + { command: Array(1000).fill("rm -rf /").join(" && ") }, + ] + for (const args of inputs) { + const g = await Critic.gate("bash", args ?? {}, Critic.basicSafetyVerifier) + expect(typeof g.allow).toBe("boolean") + } + delete process.env["ALTIMATE_CRITIC_GATE"] + }) + + test("a verifier that returns a malformed verdict is tolerated", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + const weird: Critic.Verifier = { verify: () => ({}) as any } + // ok is undefined -> falsy -> treated as a block (safe direction), never throws. + const g = await Critic.gate("bash", { command: "ls" }, weird) + expect(typeof g.allow).toBe("boolean") + delete process.env["ALTIMATE_CRITIC_GATE"] + }) + + test("ReDoS guard: a huge whitespace-heavy command returns quickly", () => { + const big = "rm" + " ".repeat(200_000) + "-rf /" + const start = performance.now() + const r = Critic.detectDangerousBash(big) + const ms = performance.now() - start + expect(r).not.toBeNull() // collapsed whitespace still matches + expect(ms).toBeLessThan(250) // no catastrophic backtracking + }) +}) diff --git a/packages/opencode/test/tool/critic-e2e.test.ts b/packages/opencode/test/tool/critic-e2e.test.ts new file mode 100644 index 000000000..072645a59 --- /dev/null +++ b/packages/opencode/test/tool/critic-e2e.test.ts @@ -0,0 +1,141 @@ +/** + * End-to-end: the critic gate wrapped around the REAL BashTool, exercising the + * exact orchestration prompt.ts uses (gate -> if allowed, execute). No mocked + * tool calls — bash actually runs and really touches the filesystem. The only + * thing never handed to the real shell is a catastrophic command: the gate must + * block it BEFORE execution, which these tests assert. + */ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Critic } from "../../src/tool/critic" +import { BashTool } from "../../src/tool/bash" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { SessionID, MessageID } from "../../src/session/schema" + +const ctx = { + sessionID: SessionID.make("ses_critic_e2e"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +/** Mirrors the prompt.ts wrapper: gate first; only execute the real tool if allowed. */ +async function gatedRun( + toolName: string, + args: Record, + realExecute: () => Promise<{ output: string }>, +): Promise<{ blocked: boolean; output: string; executed: boolean }> { + let executed = false + if (Critic.enabled()) { + const verdict = await Critic.gate(toolName, args, Critic.basicSafetyVerifier) + if (!verdict.allow) { + return { blocked: true, output: verdict.feedback ?? "", executed } + } + } + const r = await realExecute() + executed = true + return { blocked: false, output: r.output, executed } +} + +afterEach(() => delete process.env["ALTIMATE_CRITIC_GATE"]) + +describe("critic e2e — real BashTool through the gate", () => { + test("enabled: safe command passes the gate AND actually executes", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const res = await gatedRun("bash", { command: "echo CRITIC_E2E_MARKER" }, async () => { + const r = await bash.execute({ command: "echo CRITIC_E2E_MARKER", description: "echo" }, ctx) + return { output: r.output } + }) + expect(res.blocked).toBe(false) + expect(res.executed).toBe(true) + expect(res.output).toContain("CRITIC_E2E_MARKER") + }, + }) + }) + + test("enabled: a non-fatal rm passes the gate and really deletes the file", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + await using tmp = await tmpdir({ git: true }) + const victim = path.join(tmp.path, "victim.txt") + await fs.writeFile(victim, "delete me") + expect(await Bun.file(victim).exists()).toBe(true) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const res = await gatedRun("bash", { command: `rm -rf ${victim}` }, async () => { + const r = await bash.execute({ command: `rm -rf ${victim}`, description: "rm victim" }, ctx) + return { output: r.output } + }) + expect(res.blocked).toBe(false) + expect(res.executed).toBe(true) + }, + }) + // Real filesystem side-effect: the file is gone. + expect(await Bun.file(victim).exists()).toBe(false) + }) + + test("enabled: catastrophic command is blocked BEFORE the real shell runs", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + await using tmp = await tmpdir({ git: true }) + const sentinel = path.join(tmp.path, "sentinel.txt") + await fs.writeFile(sentinel, "must survive") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // The closure references the real bash with a fatal command, but the gate + // must short-circuit so it is NEVER invoked. + const res = await gatedRun("bash", { command: "rm -rf /" }, async () => { + const r = await bash.execute({ command: "rm -rf /", description: "DANGER" }, ctx) + return { output: r.output } + }) + expect(res.blocked).toBe(true) + expect(res.executed).toBe(false) + expect(res.output).toContain("Blocked by altimate verifier") + }, + }) + // Nothing executed -> the sentinel (and the machine) is untouched. + expect(await Bun.file(sentinel).exists()).toBe(true) + expect(await fs.readFile(sentinel, "utf8")).toBe("must survive") + }) + + test("disabled (default): the gate is transparent — safe commands run unchanged", async () => { + // Flag unset. gatedRun skips the gate entirely. + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const res = await gatedRun("bash", { command: "echo DISABLED_PATH" }, async () => { + const r = await bash.execute({ command: "echo DISABLED_PATH", description: "echo" }, ctx) + return { output: r.output } + }) + expect(res.blocked).toBe(false) + expect(res.executed).toBe(true) + expect(res.output).toContain("DISABLED_PATH") + }, + }) + }) + + test("disabled (default): even a catastrophic command is NOT blocked by the gate", async () => { + // Pure gate check — we never hand this to the real shell. Proves default-off + // is a true no-op so the upstream run path is unchanged. + const verdict = await Critic.gate("bash", { command: "rm -rf /" }, Critic.basicSafetyVerifier) + expect(verdict.allow).toBe(true) + expect(Critic.enabled()).toBe(false) + }) +}) diff --git a/packages/opencode/test/tool/critic.test.ts b/packages/opencode/test/tool/critic.test.ts new file mode 100644 index 000000000..374b84adc --- /dev/null +++ b/packages/opencode/test/tool/critic.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Critic } from "../../src/tool/critic" + +afterEach(() => delete process.env["ALTIMATE_CRITIC_GATE"]) + +describe("Critic.gate", () => { + test("disabled by default -> allow even a gated+denying call", async () => { + const deny: Critic.Verifier = { verify: () => ({ ok: false, reason: "x" }) } + expect((await Critic.gate("bash", {}, deny)).allow).toBe(true) + }) + + test("enabled: non-gated tool always allowed", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + expect((await Critic.gate("read", {}, Critic.ALLOW_ALL)).allow).toBe(true) + }) + + test("enabled: gated + allow-all verifier -> allow", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + expect((await Critic.gate("bash", { command: "ls" }, Critic.ALLOW_ALL)).allow).toBe(true) + }) + + test("enabled: gated + failing verifier -> block with feedback", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + const deny: Critic.Verifier = { verify: () => ({ ok: false, reason: "unsafe SQL" }) } + const g = await Critic.gate("sql_execute", { q: "drop" }, deny) + expect(g.allow).toBe(false) + expect(g.feedback).toContain("unsafe SQL") + }) + + test("enabled: verifier throws -> fail-open (allow)", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + const boom: Critic.Verifier = { + verify: () => { + throw new Error("down") + }, + } + expect((await Critic.gate("bash", {}, boom)).allow).toBe(true) + }) + + test("enabled: verifier rejects (async throw) -> fail-open (allow)", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + const boom: Critic.Verifier = { + verify: async () => { + throw new Error("async down") + }, + } + expect((await Critic.gate("bash", {}, boom)).allow).toBe(true) + }) + + test("isGated: side-effecting yes, reads no", () => { + expect(Critic.isGated("bash")).toBe(true) + expect(Critic.isGated("read")).toBe(false) + }) + + test("custom gated list overrides default", () => { + expect(Critic.isGated("bash", ["sql_execute"])).toBe(false) + expect(Critic.isGated("sql_execute", ["sql_execute"])).toBe(true) + }) +}) + +describe("Critic.detectDangerousBash — blocks catastrophic commands", () => { + const dangerous: [string, string][] = [ + ["rm -rf /", "root"], + ["rm -fr /", "flag order"], + ["rm --recursive --force /", "long flags"], + ["rm -rf /*", "root glob"], + ["rm -rf ~", "home"], + ["rm -rf ~/", "home slash"], + ["rm -rf /etc", "system path"], + ["rm -rf /usr/", "system path trailing slash"], + ["rm -rf /", "extra whitespace"], + ["sudo rm -rf /", "sudo prefix"], + ["cd /tmp && rm -rf /home", "compound command, last rm"], + [":(){ :|:& };:", "fork bomb"], + ["rm -rf --no-preserve-root /", "no-preserve-root"], + ["mkfs.ext4 /dev/sda", "mkfs on device"], + ["dd if=/dev/zero of=/dev/sda bs=1M", "dd to raw disk"], + ["echo x > /dev/sda", "redirect to raw disk"], + ["chmod -R 777 /", "recursive chmod of root"], + ["rm -rf / && rm -rf ./safe", "fatal rm first in a compound (not just the last rm)"], + ["rm -rf ./safe && rm -rf /", "fatal rm last in a compound"], + ["rm -rf /;", "separator glued to the target"], + ["rm -rf / | tee log", "fatal rm before a pipe"], + ["rm -rf / & echo bg", "backgrounded fatal rm"], + // regression — glob wipe of a system dir (was a false negative) + ["rm -rf /var/*", "glob wipe of /var"], + ["rm -rf /etc/*", "glob wipe of /etc"], + ["rm -rf /.", "root dot"], + ["rm -rf /..", "root dotdot"], + // regression — fully-qualified rm path (was a false negative) + ["/bin/rm -rf /", "fully-qualified rm"], + ["/usr/bin/rm -rf /", "fully-qualified rm in /usr/bin"], + // regression — brace expansion of $HOME (was a false negative) + ["rm -rf ${HOME}", "brace-expanded home"], + // regression — long-form / glob chmod of root (was a false negative) + ["chmod --recursive 777 /", "long-form recursive chmod of root"], + ["chmod -R 777 /*", "recursive chmod of root glob"], + ] + for (const [cmd, label] of dangerous) { + test(`blocks: ${label} — ${cmd}`, () => { + expect(Critic.detectDangerousBash(cmd)).not.toBeNull() + }) + } + + test("quote-stripping catches bash -c wrapped rm", () => { + expect(Critic.detectDangerousBash(`bash -c "rm -rf /"`)).not.toBeNull() + expect(Critic.detectDangerousBash(`r''m -rf /`)).not.toBeNull() + }) +}) + +describe("Critic.detectDangerousBash — allows ordinary commands (no false positives)", () => { + const safe = [ + "ls -la", + "echo hello", + "rm -rf ./build", + "rm -rf node_modules", + "rm -rf /tmp/my-scratch-dir", + "rm file.txt", + "rm -r /tmp/x", // recursive but not forced + "git rm -rf cached-thing", + "dd if=/dev/zero of=./disk.img bs=1M count=10", + "find . -name '*.log' -delete", + "chmod -R 755 ./scripts", + // workspace cleanup — common and safe; must NOT be blocked (no workdir context) + "rm -rf *", + "rm -rf .", + "rm -rf ./", + "rm -rf ./dist/*", + "rm -rf /var/log/myapp", // scoped subpath of a system dir, not a glob wipe + // rm mentioned as an argument, not the command — must NOT be blocked + `git commit -m "fix: avoid rm -rf / in cleanup script"`, + `echo "never run rm -rf /"`, + "echo rm -rf /", + "", + " ", + ] + for (const cmd of safe) { + test(`allows: ${JSON.stringify(cmd)}`, () => { + expect(Critic.detectDangerousBash(cmd)).toBeNull() + }) + } + + test("non-string input is safe (null)", () => { + expect(Critic.detectDangerousBash(undefined as any)).toBeNull() + expect(Critic.detectDangerousBash(null as any)).toBeNull() + expect(Critic.detectDangerousBash(42 as any)).toBeNull() + }) +}) + +describe("Critic.basicSafetyVerifier — the wired default", () => { + test("blocks catastrophic bash with a reason", () => { + const v = Critic.basicSafetyVerifier.verify("bash", { command: "rm -rf /" }) + expect((v as Critic.Verdict).ok).toBe(false) + expect((v as Critic.Verdict).reason).toContain("recursive force-delete") + }) + + test("allows ordinary bash", () => { + expect((Critic.basicSafetyVerifier.verify("bash", { command: "ls" }) as Critic.Verdict).ok).toBe(true) + }) + + test("allows non-bash gated tools (out of scope for the default heuristic)", () => { + expect((Critic.basicSafetyVerifier.verify("sql_execute", { q: "DROP DATABASE x" }) as Critic.Verdict).ok).toBe(true) + expect((Critic.basicSafetyVerifier.verify("write", { path: "/etc/passwd" }) as Critic.Verdict).ok).toBe(true) + }) + + test("missing/empty command arg is allowed", () => { + expect((Critic.basicSafetyVerifier.verify("bash", {}) as Critic.Verdict).ok).toBe(true) + expect((Critic.basicSafetyVerifier.verify("bash", { command: "" }) as Critic.Verdict).ok).toBe(true) + }) +}) + +describe("Critic.gate — end-to-end with the basic safety verifier", () => { + test("enabled: catastrophic bash is blocked with actionable feedback", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + const g = await Critic.gate("bash", { command: "rm -rf /" }, Critic.basicSafetyVerifier) + expect(g.allow).toBe(false) + expect(g.feedback).toContain("Blocked by altimate verifier") + expect(g.feedback).toContain("retry") + }) + + test("enabled: ordinary bash passes", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + const g = await Critic.gate("bash", { command: "echo hi" }, Critic.basicSafetyVerifier) + expect(g.allow).toBe(true) + }) + + test("disabled: even catastrophic bash passes (default off, no behavior change)", async () => { + const g = await Critic.gate("bash", { command: "rm -rf /" }, Critic.basicSafetyVerifier) + expect(g.allow).toBe(true) + }) + + test("enabled: a hung verifier times out and fails open (never hangs the agent)", async () => { + process.env["ALTIMATE_CRITIC_GATE"] = "1" + const hang: Critic.Verifier = { verify: () => new Promise(() => {}) } // never resolves + const start = performance.now() + const g = await Critic.gate("bash", { command: "ls" }, hang, undefined, 50) + expect(g.allow).toBe(true) + expect(performance.now() - start).toBeLessThan(2000) + }) +})