import { parse } from "@aliou/sh"; import { walkCommands, wordToString } from "../shell/ast"; /** * Dangerous command matchers for the permission gate. * * Built-in dangerous patterns are matched structurally via AST parsing. * Each matcher receives the parsed command words and returns a description * if the command is dangerous, or undefined if not matched. */ type StructuralMatcher = (words: string[]) => string | undefined; interface CommandPattern { pattern: string; description?: string; regex?: boolean; } interface CompiledCommandPattern { test: (input: string) => boolean; source: CommandPattern; } interface DangerousCommandMatch { description: string; pattern: string; } interface DangerousCommandCheckOptions { command: string; patterns: readonly CompiledCommandPattern[]; useBuiltinMatchers: boolean; fallbackPatterns: readonly CommandPattern[]; } /** * Helper to check if any word starts with a given prefix. */ function hasArg(words: string[], prefix: string): boolean { return words.some((w) => w.startsWith(prefix)); } /** * Helper to check if short options contain specific flags. * Handles grouped short options like -rf, -fr, -Rfv, etc. */ function hasShortFlag(words: string[], flag: string): boolean { return words.some( (w) => w === `-${flag}` || (w.startsWith("-") && !w.startsWith("--") && w.includes(flag)), ); } /** * Helper to check for long options. */ function hasLongOption(words: string[], option: string): boolean { return words.some((w) => w === `--${option}`); } // ============================================================================= // File/Directory Destruction // ============================================================================= /** * rm -rf, rm -r -f, rm --recursive --force, etc. * Catches recursive force delete in any form. */ const rmMatcher: StructuralMatcher = (words) => { if (words[0] !== "rm") return undefined; const hasRecursive = hasShortFlag(words, "r") || hasShortFlag(words, "R") || hasLongOption(words, "recursive") || hasLongOption(words, "dir"); const hasForce = hasShortFlag(words, "f") || hasLongOption(words, "force"); return hasRecursive && hasForce ? "recursive force delete" : undefined; }; /** * shred - secure file/device overwrite */ const shredMatcher: StructuralMatcher = (words) => { if (words[0] === "shred") return "secure file overwrite"; return undefined; }; // ============================================================================= // Privilege Escalation // ============================================================================= /** * sudo - superuser command */ const sudoMatcher: StructuralMatcher = (words) => { if (words[0] === "sudo") return "superuser command"; return undefined; }; /** * doas - privilege escalation (OpenBSD-style sudo alternative) */ const doasMatcher: StructuralMatcher = (words) => { if (words[0] === "doas") return "privileged command execution"; return undefined; }; /** * pkexec - PolicyKit privilege escalation */ const pkexecMatcher: StructuralMatcher = (words) => { if (words[0] === "pkexec") return "privileged command execution"; return undefined; }; // ============================================================================= // Disk/Filesystem Operations // ============================================================================= /** * dd of= - disk write operation * Any dd command with an output file is potentially dangerous. */ const ddMatcher: StructuralMatcher = (words) => { if (words[0] !== "dd") return undefined; return hasArg(words, "of=") ? "disk write operation" : undefined; }; /** * mkfs, mkfs.* - filesystem format */ const mkfsMatcher: StructuralMatcher = (words) => { const cmd = words[0]; if (cmd === "mkfs" || cmd?.startsWith("mkfs.")) return "filesystem format"; return undefined; }; /** * wipefs - filesystem signature wipe */ const wipefsMatcher: StructuralMatcher = (words) => { if (words[0] === "wipefs") return "filesystem signature wipe"; return undefined; }; /** * blkdiscard - block device discard (destroys data) */ const blkdiscardMatcher: StructuralMatcher = (words) => { if (words[0] === "blkdiscard") return "block device discard"; return undefined; }; // ============================================================================= // Disk Partitioning // ============================================================================= /** * fdisk, sfdisk, cfdisk - disk partitioning */ const fdiskMatcher: StructuralMatcher = (words) => { const cmd = words[0]; if (cmd === "fdisk" || cmd === "sfdisk" || cmd === "cfdisk") { return "disk partitioning"; } return undefined; }; /** * parted, sgdisk - advanced disk partitioning */ const partedMatcher: StructuralMatcher = (words) => { const cmd = words[0]; if (cmd === "parted" || cmd === "sgdisk") return "disk partitioning"; return undefined; }; // ============================================================================= // Permission Changes // ============================================================================= /** * chmod -R 777, chmod --recursive 777, chmod -R 0777, etc. * Insecure recursive world-writable permissions. */ const chmodMatcher: StructuralMatcher = (words) => { if (words[0] !== "chmod") return undefined; const hasRecursive = hasShortFlag(words, "R") || hasLongOption(words, "recursive"); const hasWorldWritable = words.some( (w) => w === "777" || w === "0777" || w === "a+rwx" || w === "ugo+rwx" || w === "7777" || // setuid/setgid/sticky + world writable w === "1777", // sticky + world writable ); return hasRecursive && hasWorldWritable ? "insecure recursive permissions" : undefined; }; /** * chown -R, chown --recursive - recursive ownership change */ const chownMatcher: StructuralMatcher = (words) => { if (words[0] !== "chown") return undefined; const hasRecursive = hasShortFlag(words, "R") || hasLongOption(words, "recursive"); return hasRecursive ? "recursive ownership change" : undefined; }; // ============================================================================= // Container Escape / Dangerous Container Operations // ============================================================================= /** * Docker/Podman dangerous run/create patterns. * Flags: --privileged, --pid=host, --network=host, --userns=host, * --uts=host, --ipc=host, -v /:/host, docker socket mounts */ const containerMatcher: StructuralMatcher = (words) => { const cmd = words[0]; if (!cmd) return undefined; // Match docker or podman commands const isDocker = cmd === "docker" || cmd === "podman"; if (!isDocker) return undefined; // Only check run and create commands (not build, pull, etc.) const subcommand = words[1]; if (subcommand !== "run" && subcommand !== "create") return undefined; // Check for dangerous flags const hasPrivileged = words.some( (w) => w === "--privileged" || w.startsWith("--privileged="), ); const hasHostPid = words.some( (w) => w === "--pid=host" || w.startsWith("--pid=host"), ); const hasHostNetwork = words.some( (w) => w === "--network=host" || w.startsWith("--network=host"), ); const hasHostUsers = words.some( (w) => w === "--userns=host" || w.startsWith("--userns=host"), ); const hasHostUts = words.some( (w) => w === "--uts=host" || w.startsWith("--uts=host"), ); const hasHostIpc = words.some( (w) => w === "--ipc=host" || w.startsWith("--ipc=host"), ); // Check for root filesystem bind mount const hasRootMount = words.some( (w) => w.startsWith("-v/:") || w.startsWith("-v/=>") || w.startsWith("--volume=/:") || w.startsWith("--mount=type=bind,source=/,"), ); // Check for docker socket mount const hasDockerSocket = words.some( (w) => w.includes("/var/run/docker.sock") || w.includes("/run/docker.sock") || w.includes("/var/run/podman.sock") || w.includes("/run/podman.sock"), ); if (hasPrivileged) return "container with privileged mode"; if (hasHostPid) return "container with host PID namespace"; if (hasHostNetwork) return "container with host network"; if (hasHostUsers) return "container with host user namespace"; if (hasHostUts) return "container with host UTS namespace"; if (hasHostIpc) return "container with host IPC"; if (hasRootMount) return "container with root filesystem mount"; if (hasDockerSocket) return "container with docker socket access"; return undefined; }; // ============================================================================= // Git Safety // ============================================================================= /** * Find the git subcommand, skipping global flags (-C , -c , etc.). */ function gitSubcommand(words: string[]): { sub: string | undefined; rest: string[]; } { let i = 1; while (i < words.length) { const w = words[i]; if ( w === "-C" || w === "-c" || w === "--git-dir" || w === "--work-tree" || w === "--namespace" ) { i += 2; continue; } if (w?.startsWith("-")) { i += 1; continue; } return { sub: w, rest: words.slice(i + 1) }; } return { sub: undefined, rest: [] }; } /** * Destructive git operations. Checkout is included deliberately: it can * overwrite uncommitted working-tree changes (e.g. another agent's work). * --force-with-lease is intentionally NOT flagged (it is the safe variant). */ const gitMatcher: StructuralMatcher = (words) => { if (words[0] !== "git") return undefined; const { sub, rest } = gitSubcommand(words); switch (sub) { case "push": { if (hasShortFlag(rest, "f") || hasLongOption(rest, "force")) { return "force push rewrites remote history"; } if (rest.includes("-d") || hasLongOption(rest, "delete")) { return "remote branch deletion"; } return undefined; } case "reset": return hasLongOption(rest, "hard") ? "hard reset discards uncommitted changes" : undefined; case "clean": return hasShortFlag(rest, "f") || hasLongOption(rest, "force") ? "removes untracked files" : undefined; case "filter-branch": return "rewrites git history"; case "branch": return rest.includes("-D") ? "force branch deletion" : undefined; case "checkout": return rest.includes("-b") ? undefined : "checkout may overwrite uncommitted changes"; case "restore": return rest.includes("--staged") && !rest.includes("--worktree") ? undefined : "restore discards working tree changes"; case "switch": return hasShortFlag(rest, "f") || hasLongOption(rest, "force") || hasLongOption(rest, "discard-changes") || rest.includes("-C") ? "forced branch switch may discard changes" : undefined; case "stash": { const stashSub = rest.find((w) => !w.startsWith("-")); return stashSub === "drop" || stashSub === "clear" ? "deletes stashed changes" : undefined; } default: return undefined; } }; // ============================================================================= // Persistence Vectors // ============================================================================= /** crontab installs from a file or stdin; only -l (list) is read-only. */ const crontabMatcher: StructuralMatcher = (words) => { if (words[0] !== "crontab") return undefined; return words.includes("-l") ? undefined : "modifies scheduled tasks"; }; const launchctlMatcher: StructuralMatcher = (words) => { if (words[0] !== "launchctl") return undefined; const sub = words[1]; return sub === "load" || sub === "bootstrap" || sub === "submit" ? "registers a persistent launch job" : undefined; }; const systemctlMatcher: StructuralMatcher = (words) => { if (words[0] !== "systemctl") return undefined; return words.includes("enable") ? "enables a persistent service" : undefined; }; // ============================================================================= // Supply Chain // ============================================================================= const npxMatcher: StructuralMatcher = (words) => { if (words[0] !== "npx") return undefined; return words.includes("-y") || words.includes("--yes") ? "runs a remote package without confirmation" : undefined; }; const URL_INSTALL = /^(https?:|git\+)/; const pipMatcher: StructuralMatcher = (words) => { if (words[0] !== "pip" && words[0] !== "pip3") return undefined; if (words[1] !== "install") return undefined; return words.some((w) => URL_INSTALL.test(w)) ? "installs a package from a URL" : undefined; }; const uvMatcher: StructuralMatcher = (words) => { if (words[0] !== "uv") return undefined; if (words[1] === "add") return "adds a project dependency"; if ( words[1] === "pip" && words[2] === "install" && words.some((w) => URL_INSTALL.test(w)) ) { return "installs a package from a URL"; } return undefined; }; // ============================================================================= // Data Exfiltration // ============================================================================= const curlUploadMatcher: StructuralMatcher = (words) => { if (words[0] !== "curl") return undefined; const uploadFlag = words.some( (w) => w === "-F" || w === "--form" || w === "-T" || w === "--upload-file" || /^-d@/.test(w) || /^--data(-\w+)?=@/.test(w), ); const dataFromFile = words.some( (w, i) => [ "-d", "--data", "--data-binary", "--data-raw", "--data-urlencode", ].includes(w) && (words[i + 1]?.startsWith("@") ?? false), ); return uploadFlag || dataFromFile ? "uploads local data to a remote host" : undefined; }; const scpMatcher: StructuralMatcher = (words) => { if (words[0] === "scp") return "copies files to a remote host"; return undefined; }; const rsyncMatcher: StructuralMatcher = (words) => { if (words[0] !== "rsync") return undefined; return words.some( (w) => !w.startsWith("-") && (w.includes(":") || w.startsWith("rsync://")), ) ? "syncs files with a remote host" : undefined; }; const ghGistMatcher: StructuralMatcher = (words) => { if (words[0] === "gh" && words[1] === "gist" && words[2] === "create") { return "publishes a gist"; } return undefined; }; const awsS3Matcher: StructuralMatcher = (words) => { if (words[0] !== "aws" || words[1] !== "s3") return undefined; if (!["cp", "sync", "mv"].includes(words[2] ?? "")) return undefined; const positional = words.slice(3).filter((w) => !w.startsWith("-")); return positional.at(-1)?.startsWith("s3://") ? "uploads data to S3" : undefined; }; // ============================================================================= // Script-Level Matchers (conditions across commands in one line) // ============================================================================= type ScriptMatcher = ( commands: string[][], rawCommand: string, ) => string | undefined; /** * `source .env && env` — sourcing is allowed for policy rules that opt in, * but printing the environment afterwards would leak the sourced secrets * into the agent's context. */ const sourceThenEnvPrintMatcher: ScriptMatcher = (commands) => { const hasSource = commands.some( (words) => words[0] === "source" || words[0] === ".", ); if (!hasSource) return undefined; const printsEnv = commands.some((words) => { const cmd = words[0]; if (cmd === "printenv") return true; if (cmd === "env") return words.slice(1).every((w) => w.startsWith("-")); if (cmd === "set" || cmd === "export") return words.length === 1; return false; }); return printsEnv ? "prints environment variables after sourcing (may expose secrets)" : undefined; }; /** curl … | sh — remote code piped straight into a shell. */ const pipeToShellMatcher: ScriptMatcher = (commands, rawCommand) => { if (!rawCommand.includes("|")) return undefined; const fetches = commands.some( (words) => words[0] === "curl" || words[0] === "wget", ); const bareShell = commands.some( (words) => ["sh", "bash", "zsh", "dash"].includes(words[0] ?? "") && words.slice(1).every((w) => w.startsWith("-")), ); return fetches && bareShell ? "pipes a remote script into a shell" : undefined; }; const SCRIPT_MATCHERS: ScriptMatcher[] = [ sourceThenEnvPrintMatcher, pipeToShellMatcher, ]; // ============================================================================= // Matcher Registry // ============================================================================= /** * All built-in dangerous command matchers. * Order matters - earlier matchers take precedence if multiple match. */ export const BUILTIN_MATCHERS: StructuralMatcher[] = [ // Destruction (highest priority) rmMatcher, shredMatcher, // Privilege escalation sudoMatcher, doasMatcher, pkexecMatcher, // Disk/filesystem operations ddMatcher, mkfsMatcher, wipefsMatcher, blkdiscardMatcher, fdiskMatcher, partedMatcher, // Permission changes chmodMatcher, chownMatcher, // Container escapes containerMatcher, // Git safety gitMatcher, // Persistence vectors crontabMatcher, launchctlMatcher, systemctlMatcher, // Supply chain npxMatcher, pipMatcher, uvMatcher, // Data exfiltration curlUploadMatcher, scpMatcher, rsyncMatcher, ghGistMatcher, awsS3Matcher, ]; /** * Keywords for each built-in matcher, used for documentation/UI. * These should match the patterns in DEFAULT_CONFIG in config.ts. */ export const BUILTIN_KEYWORD_PATTERNS = new Set([ "rm -rf", "sudo", "dd of=", "mkfs.", "chmod -R 777", "chown -R", "doas", "pkexec", "shred", "wipefs", "blkdiscard", "fdisk", "parted", "docker run --privileged", "git push --force", "git reset --hard", "git clean -f", "git filter-branch", "git branch -D", "git checkout", "git restore", "git stash drop", "crontab", "launchctl load", "systemctl enable", "npx --yes", "pip install http", "uv add", "scp", "gh gist create", ]); /** * Match a command against all built-in dangerous patterns. * Returns the first match found, or undefined if no match. */ function matchBuiltinDangerous( words: string[], ): DangerousCommandMatch | undefined { if (words.length === 0) return undefined; for (const matcher of BUILTIN_MATCHERS) { const description = matcher(words); if (description) return { description, pattern: "(structural)" }; } return undefined; } export function checkDangerousCommand({ command, patterns, useBuiltinMatchers, fallbackPatterns, }: DangerousCommandCheckOptions): DangerousCommandMatch | undefined { let parsedSuccessfully = false; if (useBuiltinMatchers) { try { const { ast } = parse(command); parsedSuccessfully = true; let match: DangerousCommandMatch | undefined; const allCommands: string[][] = []; walkCommands(ast, (cmd) => { const words = (cmd.words ?? []).map(wordToString); allCommands.push(words); const result = matchBuiltinDangerous(words); if (result) { match = result; return true; } return false; }); if (match) return match; for (const scriptMatcher of SCRIPT_MATCHERS) { const description = scriptMatcher(allCommands, command); if (description) { return { description, pattern: "(structural)" }; } } } catch { for (const pattern of fallbackPatterns) { if (command.includes(pattern.pattern)) { return { description: pattern.description ?? pattern.pattern, pattern: pattern.pattern, }; } } } } for (const compiled of patterns) { const source = compiled.source; if ( useBuiltinMatchers && parsedSuccessfully && !source.regex && BUILTIN_KEYWORD_PATTERNS.has(source.pattern) ) { continue; } if (compiled.test(command)) { return { description: source.description ?? source.pattern, pattern: source.pattern, }; } } return undefined; }