import { HISTORY_ROOT, WORKSPACE_ROOT } from "@intentic/constants"; import { type CommandClass, CommandClassSchema, type CommandLocus } from "../schemas/command-admission.js"; import { SECRET_REFERENCE } from "../schemas/needs.js"; import { inertRegions, isLive } from "../text/shell-regions.js"; // Classifies a shell command, shared by the sandbox gate and the machine agent's shell tool so they can't drift on what // a class means. A match now only means a judge should look, not a verdict, so tables should be over-inclusive, except // the hard-rule classes, which read `live` instead. // A half-open slice of the command text, in UTF-16 code units, the offsets a renderer slices with. export interface CommandSpan { readonly start: number; readonly end: number; } // One class the command fell in, and the fragments that put it there. `spans` is never empty: a class with nothing to // point at is not reported. export interface CommandMatch { readonly commandClass: CommandClass; readonly spans: readonly CommandSpan[]; // Whether a shell would run the fragment, vs text; only the hard rule ever reads this bit. readonly live: boolean; } // What the caller can check that the patterns cannot. Optional everywhere: absent means every table answers from the // command text alone. export interface CommandContext { // Where this command would run, with no default: half this catalog means something different per machine. readonly locus: CommandLocus; // true/false/undefined for holding a credential; only false drops a class, undefined still counts as a hit. readonly holdsSecret?: (path: string) => boolean | undefined; // Whether this cwd is the conversation's OWN copy, which only an isolated turn has. Absent means the tree is shared // with its owner, the fail-safe reading: a caller that has not been taught the difference gets the guard, not a // hole. Read only by git.branch-switch, whose whole question is whose checkout would move. readonly ownCheckout?: boolean; } // The `g` twin of a table's patterns, built once: the tables stay flag-free since a surviving lastIndex is how a shared // regex starts skipping matches, and `test` (the verdict path) wants that. matchAll demands `g`. const globally = (patterns: readonly RegExp[]): readonly RegExp[] => patterns.map((pattern) => new RegExp(pattern.source, `${pattern.flags}g`)); // Every occurrence of every pattern, as spans over `command`. The whole match, not a capture group: a pattern spans the // consequence (`git push … --force`), and a group would leave the verb it belongs to unmarked. const spansOf = (patterns: readonly RegExp[], command: string): CommandSpan[] => patterns.flatMap((pattern) => [...command.matchAll(pattern)].map((match) => ({ start: match.index, end: match.index + match[0].length }))); // Sorted, with overlaps folded (not adjacency: touching spans from different fragments stay two marks). Exported since // a caller marking several classes at once must also fold overlap across classes, not just within one. export const mergeSpans = (spans: readonly CommandSpan[]): CommandSpan[] => { const merged: CommandSpan[] = []; for (const span of [...spans].sort((left, right) => left.start - right.start || left.end - right.end)) { const last = merged.at(-1); if (last !== undefined && span.start < last.end) { merged[merged.length - 1] = { start: last.start, end: Math.max(last.end, span.end) }; continue; } merged.push(span); } return merged; }; const GIT_DESTRUCTIVE = [ /\bgit\s+push\b[^|;&]*\s(?:-f\b|--force\b|--force-with-lease\b|--delete\b)/, /\bgit\s+reset\b[^|;&]*\s--hard\b/, // Any `clean` that forces: it deletes untracked files, which is work no history holds a copy of. /\bgit\s+clean\b[^|;&]*\s-{1,2}[a-zA-Z]*f/, /\bgit\s+branch\b[^|;&]*\s(?:-D\b|--delete\s+--force\b|--force\s+--delete\b)/, /\bgit\s+filter-branch\b/, ]; // `switch` exists only to move HEAD, so every form of it counts. `checkout` is two commands wearing one name: with // `-- ` or a bare `.` it restores files and moves nothing, and those are the common ones, so they are cut out. const GIT_BRANCH_SWITCH = [/\bgit\s+switch\b/, /\bgit\s+checkout\b(?![^|;&]*\s--\s)(?![^|;&]*\s\.(?:\s|$))/]; // The credential is IN the command, not in a file; there is nothing for a filesystem to fact-check here. const SECRET_REFERENCES = [SECRET_REFERENCE]; // A path that usually holds a credential, a guess about a FILE, so every entry here is subject to holdsSecret. // The directory in front of a filename, so a pattern spans the whole path, not just the file inside it. const LEADING_PATH = String.raw`[\w~$.{}/\\-]*`; const CREDENTIAL_PATHS = [ // A dotenv file, not the checked-in templates beside it and not `process.env` (excluded by the lookbehind). /(? = new Map([ ["curl", "url"], ["wget", "url"], ["nc", "host"], ["ncat", "host"], ["netcat", "host"], ["telnet", "host"], ["ftp", "host"], ["ssh", "host"], ["scp", "spec"], ["sftp", "spec"], ["rsync", "spec"], ["socat", "socket"], ]); // A program name in command position: not part of a longer word or path segment (ssh-keygen, this.nc, $curl), and // followed by an argument rather than by JS punctuation, so a script's `const nc = …` is not a netcat. const programPattern = (names: readonly string[]): RegExp => new RegExp(String.raw`(??+*/%!\]}])`, "g"); const NETWORK_PROGRAM = programPattern([...NETWORK_PROGRAMS.keys()]); // Interpreters that can open a socket from inline code; a file they run is out of reach, so only -c/-e/-r counts. const INTERPRETER = programPattern([String.raw`python[23]?(?:\.\d+)?`, "node", "nodejs", "deno", "bun", "ruby", "perl", "php"]); const INLINE_CODE_FLAG = /\s(?:-[ceEpr]|--eval|--print|eval)(?=[\s='"]|$)/; // Inline code that reaches the network: a library import or call, in the spellings of the interpreters above. Read as a // whole program's intent, since the URL it opens is computed and cannot be audited from the text. const NETWORK_CODE = /\b(?:urllib\d?|http\.client|httplib|httpx|requests|aiohttp|socket|urlopen|websocket|Net::HTTP|open-uri|LWP|HTTP::Tiny|IO::Socket|fsockopen|file_get_contents|curl_exec|XMLHttpRequest|WebSocket)\b|\bfetch\s*\(|\b(?:require|import)\s*\(\s*['"](?:node:)?(?:https?|net|tls|dgram|http2)['"]|\bfrom\s+['"](?:node:)?(?:https?|net|tls|dgram|http2)['"]/; // A shell expansion: its value, and so the host it names, exists only when the command runs. const DYNAMIC = /[$`][^\s'"]*/; // One simple command: everything up to the first list or pipeline operator outside quotes. An unterminated quote runs // to the end, as a shell waiting for more input would read it. Each alternative opens on a different character, so // the walk is linear. const SEGMENT = /^(?:[^|;&\n'"`\\]|\\[\s\S]|'[^']*(?:'|$)|"(?:[^"\\]|\\[\s\S])*(?:"|$)|`[^`]*(?:`|$))*/; const segmentEnd = (command: string, from: number): number => from + (SEGMENT.exec(command.slice(from))?.[0].length ?? 0); const URL_LITERAL = /[a-zA-Z][\w+.-]*:\/\/[^\s'"\x60)]*/g; const WORD = /\S+/g; const DOTTED_HOST = /^[\w-]+(?:\.[\w-]+)+$/; // [user@]host:path and rsync's host::module; `C:\` is a Windows drive, not a host. const REMOTE_SPEC = /^(?:[^\s@/:]+@)?([^\s@/:]+):(?!\\)/; // sftp's bare user@host, which carries no colon. const USER_AT_HOST = /^[^\s@/:]+@([^\s@/:]+)$/; // socat's network address types only: TCP-LISTEN, OPEN, EXEC and the rest name no remote host. const SOCKET_ADDRESS = /^(?:tcp|udp|sctp|openssl|socks4a?|proxy)[46]?:([^\s:,]+)/i; // The host a URL names: past the scheme and any userinfo (localhost@evil.example is evil.example), before the port. const urlHost = (url: string): string => { const authority = url.replace(/^[a-zA-Z][\w+.-]*:\/\//, "").split(/[/?#]/)[0] ?? ""; const host = authority.slice(authority.lastIndexOf("@") + 1); return host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : (host.split(":")[0] ?? ""); }; // nc/ssh's destination: a word that is a dotted host or a loopback name, after any user@ and before any :port. An // undotted word is not read as a host, since flags' values (a key file, a port) look exactly like one. const bareHost = (word: string): string | undefined => { if (/^-|[=/]/.test(word)) { return undefined; } const host = word.slice(word.lastIndexOf("@") + 1); const named = LOOPBACK_HOST.test(host) ? host : (host.split(":")[0] ?? ""); return LOOPBACK_HOST.test(named) || DOTTED_HOST.test(named) ? named : undefined; }; const remoteHost = (word: string): string | undefined => /^[a-zA-Z][\w+.-]*:\/\//.test(word) ? urlHost(word) : (REMOTE_SPEC.exec(word) ?? USER_AT_HOST.exec(word))?.[1]; const HOST_OF: Readonly string | undefined>> = { url: urlHost, host: bareHost, spec: remoteHost, socket: (word) => SOCKET_ADDRESS.exec(word)?.[1], }; // A literal destination in one command's arguments, and where it ends relative to the program name. interface Destination { readonly loopback: boolean; readonly end: number; } const destinationsOf = (args: string, shape: DestinationShape): Destination[] => [...args.matchAll(shape === "url" ? URL_LITERAL : WORD)].flatMap((match) => { const host = HOST_OF[shape](unquote(match[0])); return host === undefined || host === "" ? [] : [{ loopback: LOOPBACK_HOST.test(host), end: match.index + match[0].length }]; }); // Whether one network program invocation reaches out, as the span from its name to the evidence. A literal destination // decides alone. With none, a shell expansion stands in for one: its host is unknowable, so an unknown `$BASE_URL` counts // as outbound. A literal loopback destination beside an expansion (a dynamic port, header or payload) keeps it inside. const networkReach = (command: string, start: number, shape: DestinationShape): CommandSpan | undefined => { const args = command.slice(start, segmentEnd(command, start)); const destinations = destinationsOf(args, shape); const outward = destinations.find((destination) => !destination.loopback); if (outward !== undefined) { return { start, end: start + outward.end }; } const dynamic = destinations.length === 0 ? DYNAMIC.exec(args) : null; return dynamic === null ? undefined : { start, end: start + dynamic.index + dynamic[0].length }; }; // Inline code given to an interpreter that opens the network; spans the name through the library or call that does. const interpreterReach = (command: string, start: number): CommandSpan | undefined => { const code = command.slice(start, segmentEnd(command, start)); const reach = INLINE_CODE_FLAG.test(code) ? NETWORK_CODE.exec(code) : null; return reach === null ? undefined : { start, end: start + reach.index + reach[0].length }; }; const networkOutbound = (command: string): CommandSpan[] => [ ...spansOf([FETCH_LITERAL], command), ...[...command.matchAll(NETWORK_PROGRAM)].flatMap((match) => { const shape = NETWORK_PROGRAMS.get(match[1] as string); const span = shape === undefined ? undefined : networkReach(command, match.index, shape); return span === undefined ? [] : [span]; }), ...[...command.matchAll(INTERPRETER)].flatMap((match) => { const span = interpreterReach(command, match.index); return span === undefined ? [] : [span]; }), ]; // One parse feeds both rm classes, since only the operand tells build-dir from root apart. const RM_INVOCATION = /\brm\s+([^|;&\n]*)/g; interface RmInvocation { readonly recursive: boolean; readonly force: boolean; readonly operands: readonly string[]; // Where this invocation sits, verb through last operand, so a card can point at just this delete. readonly span: CommandSpan; } // A shell word with its quoting removed, so "/work", '/work' and /work are one operand, not three. const unquote = (word: string): string => word.replace(/^['"`]|['"`]$/g, ""); const parseRm = (command: string): RmInvocation[] => { const parsed: RmInvocation[] = []; for (const invocation of command.matchAll(RM_INVOCATION)) { let recursive = false; let force = false; const operands: string[] = []; for (const word of (invocation[1] as string).trim().split(/\s+/)) { if (word === "") { continue; } // A long flag is one whole word; a short cluster is a bag of letters, so --force isn't read as f-o-r-c-e. if (word.startsWith("--")) { recursive ||= word === "--recursive"; force ||= word === "--force"; continue; } if (word.startsWith("-")) { recursive ||= /[rR]/.test(word); force ||= word.includes("f"); continue; } operands.push(unquote(word)); } parsed.push({ recursive, force, operands, span: { start: invocation.index, end: invocation.index + invocation[0].trimEnd().length } }); } return parsed; }; // A target that is a root vs inside one: a closed list per locus, since "root" differs per machine. // Two entries: the root itself, and /history, since no other conversation's data is recoverable here. const SANDBOX_ROOTS = new Set(["", HISTORY_ROOT]); // The full list: nothing on a real machine is rebuilt from an image, so every top-level OS directory counts. const DEVICE_ROOTS = new Set([ "", WORKSPACE_ROOT, HISTORY_ROOT, "/home", "/root", "/etc", "/usr", "/var", "/opt", "/boot", "/bin", "/sbin", "/lib", "/lib64", "/srv", "/dev", "/sys", "/proc", "/mnt", "/media", "/Users", "/Applications", "/System", "/Library", ]); const rootsAt = (locus: CommandLocus): ReadonlySet => (locus === "sandbox" ? SANDBOX_ROOTS : DEVICE_ROOTS); const HOME_ALIAS = /^(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%)$/; const WINDOWS_DRIVE = /^[A-Za-z]:$/; // A trailing separator or wildcard means "everything in it"; a bare * is left alone (relative to cwd). const trimTarget = (operand: string): string => operand.replace(/[/\\]\*+$/, "").replace(/[/\\]+$/, ""); const isRootTarget = (operand: string, locus: CommandLocus): boolean => { const target = trimTarget(operand); // Home directory/Windows drive are roots only on a device: the sandbox's ~ is scratch, rebuilt with the image. if (HOME_ALIAS.test(target) || WINDOWS_DRIVE.test(target)) { return locus === "device"; } // Only an absolute path can be a root; a relative path is relative to a cwd this cannot know or guess. return target === "" ? operand.startsWith("/") || operand.startsWith("\\") : rootsAt(locus).has(target); }; // Node spellings of the same deletes; recursive alone is enough, since a script has no prompt to interrupt it. const NODE_RECURSIVE_RM = /\b(?:rm|rmSync|rmdir|rmdirSync)\s*\([^)]*?recursive\s*:\s*true/; const NODE_RECURSIVE_RM_PATH = /\b(?:rm|rmSync|rmdir|rmdirSync)\s*\(\s*(['"`])([^'"`]*)\1[^)]*?recursive\s*:\s*true/g; // rimraf's whole purpose is the recursive force delete, so the call itself is the match; no options to read. const RIMRAF = /\brimraf(?:\.sync|Sync|\.native|\.rimraf)?\s*\(/; const RIMRAF_PATH = /\brimraf(?:\.sync|Sync|\.native|\.rimraf)?\s*\(\s*(['"`])([^'"`]*)\1/g; // Where a script deletes a tree, however the path reaches it. Empty means it does not. const recursiveDeletes = (program: string): CommandSpan[] => spansOf(globally([NODE_RECURSIVE_RM, RIMRAF]), program); // The literal paths a script hands to a recursive delete, each with its call. Empty when every path is computed, the // honest answer: the class above still holds, only the root question goes unasked. const nodeDeleteTargets = (program: string): { readonly target: string; readonly span: CommandSpan }[] => [...program.matchAll(NODE_RECURSIVE_RM_PATH), ...program.matchAll(RIMRAF_PATH)].map((match) => ({ target: match[2] as string, span: { start: match.index, end: match.index + match[0].length }, })); // Membership: does anything here bring the state back? A worktree, checkpoint or image restore doesn't count. // A block device wiped or overwritten, independent of locus; dd only counts writing to it, not reading one. const BLOCK_DEVICE = [ /\bmkfs(?:\.\w+)?\b/, /\bwipefs\b/, /\bblkdiscard\b/, /\bsgdisk\b[^|;&]*\s(?:--zap-all|-Z)\b/, /\bdd\b[^|;&]*\bof=(?:\/dev\/|['"`]\/dev\/)/, /\bshred\b[^|;&]*\s\/dev\//, // A redirect straight onto a disk device: the same wipe without the ceremony. />\s*\/dev\/(?:[shv]d[a-z]|nvme\d|disk\d|mmcblk\d)/, ]; // Container data, not image: ordinary here (the agent's own nested volumes); still hard-ruled on a real device. const CONTAINER_STATE = [ /\b(?:docker|podman)\s+volume\s+(?:rm|remove|prune)\b/, /\b(?:docker|podman)\s+system\s+prune\b/, /\b(?:docker(?:\s+compose|-compose)?|podman-compose)\s+down\b[^|;&]*\s(?:-v\b|--volumes\b)/, ]; const recursiveForceRms = (command: string): CommandSpan[] => parseRm(command) .filter((invocation) => invocation.recursive && invocation.force) .map((invocation) => invocation.span); // The same tree delete spelled without `rm -rf`: `find` deleting what it walks, or handing each hit to `rm`, and a list // piped into `rm` through xargs. An agent told `rm -rf` needs a card reaches for exactly these. const FIND_INVOCATION = /\bfind\s+([^|;&\n]*)/g; const FIND_DELETES = /\s(?:-delete\b|-exec(?:dir)?\s+(?:[^\s|;&]*\/)?rm\b)/; const XARGS_RM = /\bxargs\b[^|;&\n]*?\s(?:[^\s|;&]*\/)?rm\b/g; interface FindDelete { // The paths find starts walking from: the words before its first expression (`-name`, `(`, `!`). readonly starts: readonly string[]; readonly span: CommandSpan; } const parseFindDeletes = (command: string): FindDelete[] => [...command.matchAll(FIND_INVOCATION)].flatMap((invocation) => { const args = invocation[1] as string; if (!FIND_DELETES.test(` ${args}`)) { return []; } const words = args.trim().split(/\s+/); const expression = words.findIndex((word) => /^[-(!]/.test(word)); const starts = (expression === -1 ? words : words.slice(0, expression)).filter((word) => word !== "").map(unquote); return [{ starts, span: { start: invocation.index, end: invocation.index + invocation[0].trimEnd().length } }]; }); const findAndXargsDeletes = (command: string): CommandSpan[] => [ ...parseFindDeletes(command).map((found) => found.span), ...spansOf([XARGS_RM], command), ]; // The same tree delete in Windows' own shells, which is what a device's run_command runs there: PowerShell's // Remove-Item (and its aliases) with -Recurse, which it answers without asking, and cmd's rd/rmdir /s and del /s. Only // PowerShell's own names are read with a bare -r (any prefix of -Recurse, which PowerShell accepts), since `rm -r` is // also POSIX rm, whose bare -r asks before anything write-protected and is left to the rm rule above. And .NET's // Directory.Delete(path, true), the same delete from a script. const POWERSHELL_RECURSE = String.raw`\s-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?(?::\$true)?(?![\w-])`; const WINDOWS_RECURSIVE_DELETES = [ new RegExp(String.raw`(? spansOf(globally(WINDOWS_RECURSIVE_DELETES), command); // A disk wiped by Windows' own tools: a volume formatted, a disk cleared, from PowerShell or cmd. const WINDOWS_DISK_WIPES = [/(?` for a new project is not // this, so only an init of the current folder counts. const GIT_POINTER = /(?:^|[/\\])\.git[/\\]?$/; const GIT_INIT = /\bgit\s+init\b([^|;&\n]*)/g; const GIT_INIT_VALUED = /^(?:-b|--initial-branch|--template|--separate-git-dir|--object-format|--ref-format)$/; const initsHere = (args: string): boolean => { const words = args.trim().split(/\s+/).filter((word) => word !== ""); const operands: string[] = []; for (let index = 0; index < words.length; index += 1) { const word = words[index] as string; if (GIT_INIT_VALUED.test(word)) { index += 1; } else if (!word.startsWith("-")) { operands.push(unquote(word)); } } return operands.every((operand) => operand === "." || operand === "./"); }; const checkoutUnlinks = (command: string): CommandSpan[] => [ ...parseRm(command) .filter((invocation) => invocation.operands.some((operand) => GIT_POINTER.test(operand))) .map((invocation) => invocation.span), ...[...command.matchAll(GIT_INIT)] .filter((invocation) => initsHere(invocation[1] as string)) .map((invocation) => ({ start: invocation.index, end: invocation.index + invocation[0].trimEnd().length })), ]; // A recursive delete aimed at a root, in either spelling the gate is handed (shell or script). Which targets count as // roots is the locus's answer. const rootDeletes = (program: string, locus: CommandLocus): CommandSpan[] => [ ...parseRm(program) .filter((invocation) => invocation.recursive && invocation.force && invocation.operands.some((operand) => isRootTarget(operand, locus))) .map((invocation) => invocation.span), ...nodeDeleteTargets(program) .filter((delete_) => isRootTarget(delete_.target, locus)) .map((delete_) => delete_.span), ...parseFindDeletes(program) .filter((found) => found.starts.some((start) => isRootTarget(start, locus))) .map((found) => found.span), ]; // The g twins, built once at load: a card is minted per command held, classify runs per command typed. const GIT_DESTRUCTIVE_G = globally(GIT_DESTRUCTIVE); const GIT_BRANCH_SWITCH_G = globally(GIT_BRANCH_SWITCH); const SECRET_REFERENCES_G = globally(SECRET_REFERENCES); const CREDENTIAL_PATHS_G = globally(CREDENTIAL_PATHS); const PACKAGE_PUBLISH_G = globally(PACKAGE_PUBLISH); const BLOCK_DEVICE_G = globally(BLOCK_DEVICE); const CONTAINER_STATE_G = globally(CONTAINER_STATE); // The word a matched fragment sits in, so the fact-check asks about the file the command would actually open, not the // suffix that fired. A wrong guess just resolves to an unreadable path, the old behaviour, not a hole. const WORD_EDGE = /[\s'"`;|&()]/; const enclosingPath = (command: string, span: CommandSpan): string => { let start = span.start; while (start > 0 && !WORD_EDGE.test(command[start - 1] as string)) { start -= 1; } let end = span.end; while (end < command.length && !WORD_EDGE.test(command[end] as string)) { end += 1; } return command .slice(start, end) .replace(/^-{1,2}[\w-]+=/, "") .replace(/^[@<>=]+/, ""); }; // Tells a search pattern from a real path before trusting the table's filename guess: an escaped dot, a character // class, or a class escape marks a regex, not a path, except on a Windows path spelled the same way. const CHARACTER_CLASS = /\[[^\]]*\]/; const CLASS_ESCAPE = /\\[wdsbWDSB](?!\w)/; const ESCAPED_DOT = /\\\./; const PATH_SEPARATOR = /\\\w/; const namesAPattern = (word: string): boolean => CHARACTER_CLASS.test(word) || CLASS_ESCAPE.test(word) || (ESCAPED_DOT.test(word) && !PATH_SEPARATOR.test(word)); // Every secret reference plus every credential-shaped path the context didn't positively clear. Compared against // `false`, not `true`: an `undefined` (could not check) must still count as a hit. const credentialReads = (command: string, context: CommandContext): CommandSpan[] => [ ...spansOf(SECRET_REFERENCES_G, command), ...spansOf(CREDENTIAL_PATHS_G, command).filter((span) => { const word = enclosingPath(command, span); return !namesAPattern(word) && context.holdsSecret?.(word) !== false; }), ]; // One function per class; empty means the command is not in it, so membership and evidence are the same walk. const MATCHES: Readonly CommandSpan[]>> = { "git.destructive": (command) => [...spansOf(GIT_DESTRUCTIVE_G, command), ...checkoutUnlinks(command)], // The only class that can answer "not here": in its own copy a conversation's checkout is its own to move. "git.branch-switch": (command, context) => (context.ownCheckout === true ? [] : spansOf(GIT_BRANCH_SWITCH_G, command)), "files.destructive": (command) => [ ...recursiveForceRms(command), ...recursiveDeletes(command), ...findAndXargsDeletes(command), ...windowsDeletes(command), ], "system.destructive": (command, context) => [ ...spansOf(BLOCK_DEVICE_G, command), ...spansOf(globally(WINDOWS_DISK_WIPES), command), ...rootDeletes(command, context.locus), ], "container.state": (command) => spansOf(CONTAINER_STATE_G, command), "secrets.access": credentialReads, "package.publish": (command) => spansOf(PACKAGE_PUBLISH_G, command), "network.outbound": networkOutbound, }; // Every class the command falls in, with its fragments, in the catalog's own order. `context` is now required: its // locus decides what half the catalog means, and the inert scan runs once, shared by every class. export const matchCommand = (command: string, context: CommandContext): CommandMatch[] => { const regions = inertRegions(command); return CommandClassSchema.options.flatMap((commandClass) => { const spans = mergeSpans(MATCHES[commandClass](command, context)); return spans.length === 0 ? [] : [{ commandClass, spans, live: spans.some((span) => isLive(span, regions)) }]; }); }; // Every class the command falls in, for callers that only need a verdict (a rulebook consult, a scope switch). export const classifyCommand = (command: string, context: CommandContext): CommandClass[] => matchCommand(command, context).map((match) => match.commandClass); // What the card says the command would do. The class name is a settings key, not a sentence to show a person. export const COMMAND_CLASS_LABELS: Readonly> = { "git.destructive": "rewrite or discard git history", "git.branch-switch": "move a checkout you share with this conversation onto another branch", "files.destructive": "delete files recursively", "system.destructive": "wipe a disk, or delete a whole root directory", "container.state": "delete a container volume or the data in it", "secrets.access": "read credential material", "package.publish": "publish or release a package", "network.outbound": "send a request out to the internet", }; // One fragment that fires a class, split so `code` is shell or script alone (highlightable) and `qualifier` carries // what narrows it, in prose: a sentence with a command inside it is unhighlightable. export interface CommandPattern { /** The literal a shell or a script would carry. Highlightable on its own; never a sentence. */ readonly code: string; /** What narrows it, or the spellings folded into it. Prose, and rendered as prose. */ readonly qualifier?: string; } // The catalog's patterns for the Safety page's human panel; one fragment per entry, not one line per regex. export const COMMAND_CLASS_PATTERNS: Readonly> = { "git.destructive": [ { code: "git push --force", qualifier: "also -f and --force-with-lease" }, { code: "git push --delete" }, { code: "git reset --hard" }, { code: "git clean -f" }, { code: "git branch -D" }, { code: "git filter-branch" }, { code: "rm .git", qualifier: "any flags: a checkout's link to its history" }, { code: "git init", qualifier: "only in the current folder, which may already be a checkout" }, ], "git.branch-switch": [ { code: "git switch " }, { code: "git checkout ", qualifier: "not `git checkout -- ` or `git checkout .`, which restore files and move nothing" }, ], "files.destructive": [ { code: "rm -rf " }, { code: "fs.rm(, { recursive: true })", qualifier: "also rmSync, rmdir, rmdirSync" }, { code: "rimraf()" }, { code: "find -delete", qualifier: "also -exec rm and -execdir rm" }, { code: "xargs rm" }, { code: "Remove-Item -Recurse ", qualifier: "also its aliases ri, rd, rmdir, del, erase, and rm -Recurse" }, { code: "rd /s ", qualifier: "also rmdir /s and del /s" }, { code: "[IO.Directory]::Delete(, $true)" }, ], "system.destructive": [ { code: "mkfs" }, { code: "wipefs" }, { code: "blkdiscard" }, { code: "sgdisk --zap-all" }, { code: "dd of=/dev/…" }, { code: "shred /dev/…" }, { code: "> /dev/sda" }, { code: "Format-Volume", qualifier: "also Clear-Disk, and format C:" }, { code: "rm -rf /", qualifier: "also find / -delete; only when the target is a root, listed below" }, ], "container.state": [ { code: "docker volume rm", qualifier: "also remove, prune, and podman for any of these" }, { code: "docker system prune" }, { code: "docker compose down -v" }, ], "secrets.access": [ { code: "{{secret:NAME}}", qualifier: "a stored secret, used in the command itself" }, { code: ".env" }, { code: ".ssh/*" }, { code: "id_rsa" }, { code: ".aws/credentials" }, { code: ".npmrc" }, { code: ".git-credentials" }, ], "package.publish": [ { code: "npm publish", qualifier: "also pnpm, yarn, bun" }, { code: "cargo publish" }, { code: "gh release create" }, { code: "docker push" }, { code: "twine upload" }, ], "network.outbound": [ { code: "curl https://…", qualifier: "also wget; a literal loopback address does not count, even with a variable port" }, { code: "curl $URL", qualifier: "a destination built at run time counts, since its host can't be read beforehand" }, { code: "nc host.example 443", qualifier: "also ncat, netcat, telnet, ftp and ssh" }, { code: "scp file host:/path", qualifier: "also sftp and rsync with a remote side, and socat TCP:host:port" }, { code: "python3 -c 'import urllib…'", qualifier: "any interpreter's inline code that opens a connection" }, { code: 'fetch("https://…")', qualifier: "in a script" }, ], }; // No verdict set lives here: which classes stop a command is now a policy question, answered elsewhere.