diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index d63be8c75..ae63dae61 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -154,8 +154,9 @@ describe("exec approvals shell allowlist (chained commands)", () => { it("rejects chained commands when any part is not allowlisted", () => { const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/obsidian-cli" }]; + // Use a non-blocklisted command to test allowlist rejection const result = evaluateShellAllowlist({ - command: "/usr/bin/obsidian-cli print-default && /usr/bin/rm -rf /", + command: "/usr/bin/obsidian-cli print-default && /usr/bin/unknown-cmd", allowlist, safeBins: new Set(), cwd: "/tmp", @@ -164,6 +165,21 @@ describe("exec approvals shell allowlist (chained commands)", () => { expect(result.allowlistSatisfied).toBe(false); }); + it("rejects blocklisted commands before checking allowlist", () => { + const allowlist: ExecAllowlistEntry[] = [{ pattern: "rm" }]; + // Even with 'rm' on allowlist, 'rm -rf /' should be blocked by blocklist + const result = evaluateShellAllowlist({ + command: "rm -rf /", + allowlist, + safeBins: new Set(), + cwd: "/tmp", + }); + // Blocklist rejection sets analysisOk to false + expect(result.analysisOk).toBe(false); + expect(result.blocklistResult?.blocked).toBe(true); + expect(result.blocklistResult?.severity).toBe("critical"); + }); + it("returns analysisOk=false for malformed chains", () => { const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/echo" }]; const result = evaluateShellAllowlist({ diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 0830ed89a..a31942870 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -5,6 +5,24 @@ import os from "node:os"; import path from "node:path"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; +import { + evaluateBlocklist, + formatBlocklistReason, + isOnBlocklist, + getBlocklistPatterns, + type BlocklistConfig, + type BlocklistResult, +} from "./exec-blocklist.js"; + +// Re-export blocklist utilities for consumers +export { + evaluateBlocklist, + formatBlocklistReason, + isOnBlocklist, + getBlocklistPatterns, + type BlocklistConfig, + type BlocklistResult, +} from "./exec-blocklist.js"; export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecSecurity = "deny" | "allowlist" | "full"; @@ -1045,10 +1063,13 @@ export type ExecAllowlistAnalysis = { allowlistSatisfied: boolean; allowlistMatches: ExecAllowlistEntry[]; segments: ExecCommandSegment[]; + /** Blocklist evaluation result (if blocklist check was performed) */ + blocklistResult?: BlocklistResult; }; /** * Evaluates allowlist for shell commands (including &&, ||, ;) and returns analysis metadata. + * Also checks the security blocklist FIRST to reject dangerous commands. */ export function evaluateShellAllowlist(params: { command: string; @@ -1058,6 +1079,46 @@ export function evaluateShellAllowlist(params: { env?: NodeJS.ProcessEnv; skillBins?: Set; autoAllowSkills?: boolean; + /** Configuration for blocklist evaluation (optional) */ + blocklistConfig?: Partial; + /** Skip blocklist check (use with extreme caution, only for sandbox environments) */ + skipBlocklist?: boolean; +}): ExecAllowlistAnalysis { + // SECURITY: Check blocklist FIRST before any other evaluation + // This ensures dangerous commands are blocked regardless of allowlist + if (!params.skipBlocklist) { + const blocklistResult = evaluateBlocklist(params.command, params.blocklistConfig); + if (blocklistResult.blocked) { + return { + analysisOk: false, // Treat blocked commands as analysis failure + allowlistSatisfied: false, + allowlistMatches: [], + segments: [], + blocklistResult, + }; + } + // Even if not blocked, include the result for logging/audit + if (blocklistResult.matchedPatterns.length > 0) { + // Continue with allowlist evaluation but include blocklist result + const result = evaluateShellAllowlistInternal(params); + return { ...result, blocklistResult }; + } + } + + return evaluateShellAllowlistInternal(params); +} + +/** + * Internal allowlist evaluation (called after blocklist check). + */ +function evaluateShellAllowlistInternal(params: { + command: string; + allowlist: ExecAllowlistEntry[]; + safeBins: Set; + cwd?: string; + env?: NodeJS.ProcessEnv; + skillBins?: Set; + autoAllowSkills?: boolean; }): ExecAllowlistAnalysis { const chainParts = splitCommandChain(params.command); if (!chainParts) { diff --git a/src/infra/exec-blocklist.test.ts b/src/infra/exec-blocklist.test.ts new file mode 100644 index 000000000..f31cabc19 --- /dev/null +++ b/src/infra/exec-blocklist.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, test } from "vitest"; +import { + evaluateBlocklist, + isOnBlocklist, + formatBlocklistReason, + getBlocklistPatterns, +} from "./exec-blocklist.js"; + +describe("evaluateBlocklist", () => { + describe("critical severity commands", () => { + test("blocks rm -rf /", () => { + const result = evaluateBlocklist("rm -rf /"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + expect(result.matchedPatterns.length).toBeGreaterThan(0); + }); + + test("blocks rm -rf ~", () => { + const result = evaluateBlocklist("rm -rf ~"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + }); + + test("blocks dd to disk device", () => { + const result = evaluateBlocklist("dd if=/dev/zero of=/dev/sda"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + expect(result.matchedPatterns).toContain("dd to raw disk device"); + }); + + test("blocks mkfs on disk", () => { + const result = evaluateBlocklist("mkfs.ext4 /dev/sda1"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + }); + + test("blocks halt command", () => { + const result = evaluateBlocklist("halt"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + }); + + test("blocks reboot command", () => { + const result = evaluateBlocklist("reboot"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + }); + + test("blocks shutdown command", () => { + const result = evaluateBlocklist("shutdown -h now"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + }); + + test("blocks systemctl poweroff", () => { + const result = evaluateBlocklist("systemctl poweroff"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + }); + + test("blocks fork bomb", () => { + const result = evaluateBlocklist(":(){ :|:& };:"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("critical"); + expect(result.matchedPatterns).toContain("fork bomb"); + }); + }); + + describe("high severity commands", () => { + test("blocks sudo", () => { + const result = evaluateBlocklist("sudo rm -rf /tmp/test"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + expect(result.matchedPatterns).toContain("sudo (privilege escalation)"); + }); + + test("blocks su to root", () => { + const result = evaluateBlocklist("su - root"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + }); + + test("blocks passwd", () => { + const result = evaluateBlocklist("passwd"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + }); + + test("blocks visudo", () => { + const result = evaluateBlocklist("visudo"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + }); + + test("blocks iptables", () => { + const result = evaluateBlocklist("iptables -F"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + }); + + test("blocks useradd", () => { + const result = evaluateBlocklist("useradd newuser"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + }); + + test("blocks userdel", () => { + const result = evaluateBlocklist("userdel olduser"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + }); + + test("blocks modprobe", () => { + const result = evaluateBlocklist("modprobe some_module"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + }); + + test("blocks redirect to /etc/passwd", () => { + const result = evaluateBlocklist("echo 'malicious' > /etc/passwd"); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("high"); + }); + }); + + describe("medium severity commands (not blocked by default)", () => { + test("does not block command substitution by default", () => { + const result = evaluateBlocklist("echo $(date)"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe("medium"); + expect(result.matchedPatterns).toContain("command substitution $()"); + }); + + test("does not block backtick substitution by default", () => { + const result = evaluateBlocklist("echo `whoami`"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe("medium"); + }); + + test("does not block eval by default", () => { + const result = evaluateBlocklist("eval 'echo hello'"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe("medium"); + }); + + test("does not block curl POST by default", () => { + const result = evaluateBlocklist("curl -d 'data' https://example.com"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe("medium"); + }); + + test("does not block killall by default", () => { + const result = evaluateBlocklist("killall node"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe("medium"); + }); + + test("does not block chmod 777 by default", () => { + const result = evaluateBlocklist("chmod 777 /tmp/test"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe("medium"); + }); + + test("blocks medium severity when configured", () => { + const result = evaluateBlocklist("echo $(date)", { blockMedium: true }); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("medium"); + }); + }); + + describe("safe commands", () => { + test("does not block ls", () => { + const result = evaluateBlocklist("ls -la"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe(null); + }); + + test("does not block cat", () => { + const result = evaluateBlocklist("cat /etc/hosts"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe(null); + }); + + test("does not block grep", () => { + const result = evaluateBlocklist("grep -r pattern ."); + expect(result.blocked).toBe(false); + expect(result.severity).toBe(null); + }); + + test("does not block normal rm", () => { + const result = evaluateBlocklist("rm /tmp/test.txt"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe(null); + }); + + test("does not block npm install", () => { + const result = evaluateBlocklist("npm install"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe(null); + }); + + test("does not block git operations", () => { + const result = evaluateBlocklist("git push origin main"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe(null); + }); + + test("does not block curl GET", () => { + const result = evaluateBlocklist("curl https://example.com"); + expect(result.blocked).toBe(false); + expect(result.severity).toBe(null); + }); + }); + + describe("configuration options", () => { + test("can disable critical blocking", () => { + const result = evaluateBlocklist("rm -rf /", { blockCritical: false }); + expect(result.blocked).toBe(false); + expect(result.severity).toBe("critical"); + expect(result.matchedPatterns.length).toBeGreaterThan(0); + }); + + test("can disable high blocking", () => { + const result = evaluateBlocklist("sudo ls", { blockHigh: false }); + expect(result.blocked).toBe(false); + expect(result.severity).toBe("high"); + }); + + test("can enable medium blocking", () => { + const result = evaluateBlocklist("eval 'test'", { blockMedium: true }); + expect(result.blocked).toBe(true); + expect(result.severity).toBe("medium"); + }); + }); +}); + +describe("isOnBlocklist", () => { + test("returns true for blocked commands", () => { + expect(isOnBlocklist("rm -rf /")).toBe(true); + expect(isOnBlocklist("sudo ls")).toBe(true); + expect(isOnBlocklist("echo $(date)")).toBe(true); + }); + + test("returns false for safe commands", () => { + expect(isOnBlocklist("ls -la")).toBe(false); + expect(isOnBlocklist("cat file.txt")).toBe(false); + expect(isOnBlocklist("npm install")).toBe(false); + }); +}); + +describe("formatBlocklistReason", () => { + test("formats blocked command reason", () => { + const result = evaluateBlocklist("rm -rf /"); + const formatted = formatBlocklistReason(result); + expect(formatted).toContain("blocked"); + expect(formatted).toContain("critical"); + expect(formatted).toContain("catastrophic"); + }); + + test("formats allowed command", () => { + const result = evaluateBlocklist("ls -la"); + const formatted = formatBlocklistReason(result); + expect(formatted).toContain("allowed"); + }); +}); + +describe("getBlocklistPatterns", () => { + test("returns all patterns", () => { + const patterns = getBlocklistPatterns(); + expect(Array.isArray(patterns)).toBe(true); + expect(patterns.length).toBeGreaterThan(0); + + // Check structure + const first = patterns[0]; + expect(first).toHaveProperty("pattern"); + expect(first).toHaveProperty("description"); + expect(first).toHaveProperty("severity"); + }); + + test("includes critical patterns", () => { + const patterns = getBlocklistPatterns(); + const critical = patterns.filter((p) => p.severity === "critical"); + expect(critical.length).toBeGreaterThan(0); + }); + + test("includes high patterns", () => { + const patterns = getBlocklistPatterns(); + const high = patterns.filter((p) => p.severity === "high"); + expect(high.length).toBeGreaterThan(0); + }); + + test("includes medium patterns", () => { + const patterns = getBlocklistPatterns(); + const medium = patterns.filter((p) => p.severity === "medium"); + expect(medium.length).toBeGreaterThan(0); + }); +}); + +describe("edge cases", () => { + test("handles empty command", () => { + const result = evaluateBlocklist(""); + expect(result.blocked).toBe(false); + expect(result.matchedPatterns.length).toBe(0); + }); + + test("handles command with spaces only", () => { + const result = evaluateBlocklist(" "); + expect(result.blocked).toBe(false); + }); + + test("handles multiline command", () => { + const result = evaluateBlocklist("ls -la\nrm -rf /"); + expect(result.blocked).toBe(true); + }); + + test("handles case variations", () => { + const result = evaluateBlocklist("SUDO ls"); + expect(result.blocked).toBe(true); + }); +}); diff --git a/src/infra/exec-blocklist.ts b/src/infra/exec-blocklist.ts new file mode 100644 index 000000000..c6cc6ad7b --- /dev/null +++ b/src/infra/exec-blocklist.ts @@ -0,0 +1,514 @@ +/** + * Command Execution Blocklist + * + * This module provides a security layer that blocks dangerous commands before + * they reach the allowlist evaluation. Commands matching blocklist patterns + * are ALWAYS blocked regardless of allowlist settings or elevated permissions. + * + * The blocklist is designed to prevent catastrophic system damage from + * commands that should almost never be run via an AI assistant. + */ + +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("exec").child("blocklist"); + +/** Result of blocklist evaluation */ +export type BlocklistResult = { + blocked: boolean; + reason: string | null; + matchedPatterns: string[]; + severity: "critical" | "high" | "medium" | null; +}; + +/** Blocklist entry with pattern and metadata */ +type BlocklistEntry = { + pattern: RegExp; + description: string; + severity: "critical" | "high" | "medium"; + /** If true, requires explicit user approval even in elevated mode */ + requiresExplicitApproval: boolean; +}; + +/** + * CRITICAL: Commands that can cause catastrophic, irreversible system damage. + * These are ALWAYS blocked without any override option. + */ +const CRITICAL_BLOCKLIST: BlocklistEntry[] = [ + // Destructive filesystem operations + { + pattern: /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?[\/~]\s*$/i, + description: "rm -rf / (root filesystem deletion)", + severity: "critical", + requiresExplicitApproval: true, + }, + { + pattern: /\brm\s+(-[a-zA-Z]*[rf]+[a-zA-Z]*\s+)+\s*\/\s*$/i, + description: "rm -rf / (root filesystem deletion)", + severity: "critical", + requiresExplicitApproval: true, + }, + { + pattern: /\brm\s+(-[a-zA-Z]*[rf]+[a-zA-Z]*\s+)+\s*~\s*$/i, + description: "rm -rf ~ (home directory deletion)", + severity: "critical", + requiresExplicitApproval: true, + }, + + // Disk/filesystem destruction + { + pattern: /\bdd\s+.*\bof\s*=\s*\/dev\/(sd[a-z]|hd[a-z]|nvme\d+n\d+|disk\d+)\b/i, + description: "dd to raw disk device", + severity: "critical", + requiresExplicitApproval: true, + }, + { + pattern: /\bmkfs(\.[a-z0-9]+)?\s+.*\/dev\/(sd[a-z]|hd[a-z]|nvme\d+n\d+|disk\d+)/i, + description: "mkfs on disk device (filesystem destruction)", + severity: "critical", + requiresExplicitApproval: true, + }, + { + pattern: /\bfdisk\s+.*\/dev\/(sd[a-z]|hd[a-z]|nvme\d+n\d+|disk\d+)/i, + description: "fdisk partition manipulation", + severity: "critical", + requiresExplicitApproval: true, + }, + { + pattern: /\bparted\s+.*\/dev\/(sd[a-z]|hd[a-z]|nvme\d+n\d+|disk\d+)/i, + description: "parted partition manipulation", + severity: "critical", + requiresExplicitApproval: true, + }, + + // System control commands + { + pattern: /\b(halt|poweroff|reboot|shutdown)\b/i, + description: "system halt/reboot command", + severity: "critical", + requiresExplicitApproval: true, + }, + { + pattern: /\binit\s+[0-6]\b/i, + description: "init runlevel change", + severity: "critical", + requiresExplicitApproval: true, + }, + { + pattern: /\bsystemctl\s+(halt|poweroff|reboot|suspend|hibernate)\b/i, + description: "systemctl power control", + severity: "critical", + requiresExplicitApproval: true, + }, + + // Fork bomb patterns + { + pattern: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, + description: "fork bomb", + severity: "critical", + requiresExplicitApproval: true, + }, + { + pattern: /\bwhile\s+true\s*;\s*do\s*:\s*;\s*done\s*&/i, + description: "infinite loop fork", + severity: "critical", + requiresExplicitApproval: true, + }, +]; + +/** + * HIGH: Commands that can cause significant security issues or data loss. + * These require explicit user approval. + */ +const HIGH_BLOCKLIST: BlocklistEntry[] = [ + // Privilege escalation + { + pattern: /\bsudo\s+/i, + description: "sudo (privilege escalation)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bsu\s+(-\s+)?root\b/i, + description: "su to root", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bdoas\s+/i, + description: "doas (privilege escalation)", + severity: "high", + requiresExplicitApproval: true, + }, + + // Credential manipulation + { + pattern: /\bpasswd\b/i, + description: "passwd (password change)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bchpasswd\b/i, + description: "chpasswd (bulk password change)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bvisudo\b/i, + description: "visudo (sudoers modification)", + severity: "high", + requiresExplicitApproval: true, + }, + + // Firewall and network security + { + pattern: /\biptables\s+/i, + description: "iptables (firewall rules)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bip6tables\s+/i, + description: "ip6tables (IPv6 firewall rules)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bnft\s+/i, + description: "nftables (firewall rules)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bufw\s+(disable|reset|delete)/i, + description: "ufw firewall disable/reset", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bfirewall-cmd\s+/i, + description: "firewalld manipulation", + severity: "high", + requiresExplicitApproval: true, + }, + + // User/group manipulation + { + pattern: /\buseradd\b/i, + description: "useradd (user creation)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\buserdel\b/i, + description: "userdel (user deletion)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\busermod\b/i, + description: "usermod (user modification)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bgroupadd\b/i, + description: "groupadd (group creation)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bgroupdel\b/i, + description: "groupdel (group deletion)", + severity: "high", + requiresExplicitApproval: true, + }, + + // SSH key manipulation + { + pattern: /\bssh-keygen\s+.*(-[a-zA-Z]*f[a-zA-Z]*\s+)?~\/\.ssh\/(id_|authorized_keys)/i, + description: "SSH key overwrite", + severity: "high", + requiresExplicitApproval: true, + }, + + // Kernel/boot manipulation + { + pattern: /\bmodprobe\s+/i, + description: "modprobe (kernel module loading)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\binsmod\s+/i, + description: "insmod (kernel module insertion)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\brmmod\s+/i, + description: "rmmod (kernel module removal)", + severity: "high", + requiresExplicitApproval: true, + }, + + // Cron manipulation (persistence) + { + pattern: /\bcrontab\s+-[a-zA-Z]*e/i, + description: "crontab edit", + severity: "high", + requiresExplicitApproval: true, + }, + + // System config files + { + pattern: />\s*\/etc\/(passwd|shadow|sudoers|hosts|fstab|ssh)/i, + description: "redirect to system config file", + severity: "high", + requiresExplicitApproval: true, + }, + + // macOS specific + { + pattern: /\bcsrutil\s+(disable|enable)\b/i, + description: "csrutil (SIP modification)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bspctl\s+/i, + description: "spctl (Gatekeeper manipulation)", + severity: "high", + requiresExplicitApproval: true, + }, + { + pattern: /\bdscl\s+.*-passwd/i, + description: "dscl password change", + severity: "high", + requiresExplicitApproval: true, + }, +]; + +/** + * MEDIUM: Commands that should be reviewed but may have legitimate uses. + * These log warnings but don't block by default. + */ +const MEDIUM_BLOCKLIST: BlocklistEntry[] = [ + // Shell operators that could enable injection + { + pattern: /\$\([^)]+\)/, + description: "command substitution $()", + severity: "medium", + requiresExplicitApproval: false, + }, + { + pattern: /`[^`]+`/, + description: "backtick command substitution", + severity: "medium", + requiresExplicitApproval: false, + }, + { + pattern: /\beval\s+/i, + description: "eval (arbitrary code execution)", + severity: "medium", + requiresExplicitApproval: false, + }, + + // Network data exfiltration + { + pattern: /\bcurl\s+.*-[a-zA-Z]*d\s+/i, + description: "curl POST data", + severity: "medium", + requiresExplicitApproval: false, + }, + { + pattern: /\bwget\s+.*--post/i, + description: "wget POST", + severity: "medium", + requiresExplicitApproval: false, + }, + + // Process manipulation + { + pattern: /\bkillall\s+/i, + description: "killall (mass process termination)", + severity: "medium", + requiresExplicitApproval: false, + }, + { + pattern: /\bpkill\s+-9\s+/i, + description: "pkill -9 (force kill)", + severity: "medium", + requiresExplicitApproval: false, + }, + + // File permission changes + { + pattern: /\bchmod\s+777\s+/i, + description: "chmod 777 (world-writable)", + severity: "medium", + requiresExplicitApproval: false, + }, + { + pattern: /\bchmod\s+-[a-zA-Z]*R[a-zA-Z]*\s+/i, + description: "chmod recursive", + severity: "medium", + requiresExplicitApproval: false, + }, + { + pattern: /\bchown\s+-[a-zA-Z]*R[a-zA-Z]*\s+/i, + description: "chown recursive", + severity: "medium", + requiresExplicitApproval: false, + }, +]; + +/** Combined blocklist for evaluation */ +const BLOCKLIST = [...CRITICAL_BLOCKLIST, ...HIGH_BLOCKLIST, ...MEDIUM_BLOCKLIST]; + +/** + * Configuration for blocklist evaluation behavior. + */ +export type BlocklistConfig = { + /** Block critical severity commands (default: true) */ + blockCritical: boolean; + /** Block high severity commands (default: true) */ + blockHigh: boolean; + /** Block medium severity commands (default: false, just warn) */ + blockMedium: boolean; + /** Log all blocklist matches */ + logMatches: boolean; + /** Allow bypass for explicitly approved commands */ + allowExplicitApproval: boolean; +}; + +const DEFAULT_BLOCKLIST_CONFIG: BlocklistConfig = { + blockCritical: true, + blockHigh: true, + blockMedium: false, + logMatches: true, + allowExplicitApproval: false, // Even explicit approval cannot bypass critical/high by default +}; + +/** + * Evaluates a command against the blocklist. + * Returns whether the command should be blocked and why. + */ +export function evaluateBlocklist( + command: string, + config: Partial = {}, +): BlocklistResult { + const cfg = { ...DEFAULT_BLOCKLIST_CONFIG, ...config }; + const matchedPatterns: string[] = []; + let highestSeverity: "critical" | "high" | "medium" | null = null; + let blocked = false; + let reason: string | null = null; + + for (const entry of BLOCKLIST) { + if (entry.pattern.test(command)) { + matchedPatterns.push(entry.description); + + // Track highest severity + if ( + highestSeverity === null || + entry.severity === "critical" || + (entry.severity === "high" && highestSeverity !== "critical") + ) { + highestSeverity = entry.severity; + } + + // Determine if we should block + if (entry.severity === "critical" && cfg.blockCritical) { + blocked = true; + reason = `BLOCKED: ${entry.description} (critical severity)`; + } else if (entry.severity === "high" && cfg.blockHigh) { + blocked = true; + reason = `BLOCKED: ${entry.description} (high severity)`; + } else if (entry.severity === "medium" && cfg.blockMedium) { + blocked = true; + reason = `BLOCKED: ${entry.description} (medium severity)`; + } + } + } + + // Log matches if configured + if (cfg.logMatches && matchedPatterns.length > 0) { + const logLevel = blocked ? "error" : highestSeverity === "medium" ? "warn" : "error"; + const preview = command.length > 80 ? command.slice(0, 80) + "..." : command; + + if (logLevel === "error") { + log.error("Command blocklist match", { + blocked, + severity: highestSeverity, + patterns: matchedPatterns, + command: preview, + }); + } else { + log.warn("Command blocklist match", { + blocked, + severity: highestSeverity, + patterns: matchedPatterns, + command: preview, + }); + } + } + + return { + blocked, + reason, + matchedPatterns, + severity: highestSeverity, + }; +} + +/** + * Quick check if a command is on the blocklist (any severity). + * Use evaluateBlocklist for full analysis. + */ +export function isOnBlocklist(command: string): boolean { + return BLOCKLIST.some((entry) => entry.pattern.test(command)); +} + +/** + * Returns a human-readable explanation of why a command was blocked. + */ +export function formatBlocklistReason(result: BlocklistResult): string { + if (!result.blocked) { + return "Command is allowed"; + } + + const lines = [ + `Command blocked due to security policy.`, + `Severity: ${result.severity}`, + `Matched patterns:`, + ...result.matchedPatterns.map((p) => ` - ${p}`), + ]; + + if (result.severity === "critical") { + lines.push(""); + lines.push("Critical severity commands cannot be approved and are always blocked."); + lines.push("These commands can cause catastrophic, irreversible system damage."); + } else if (result.severity === "high") { + lines.push(""); + lines.push("High severity commands require explicit user approval."); + lines.push("If you need to run this command, please do so directly in your terminal."); + } + + return lines.join("\n"); +} + +/** + * Get all blocklist patterns for documentation/display purposes. + */ +export function getBlocklistPatterns(): Array<{ + pattern: string; + description: string; + severity: "critical" | "high" | "medium"; +}> { + return BLOCKLIST.map((entry) => ({ + pattern: entry.pattern.source, + description: entry.description, + severity: entry.severity, + })); +}