import { readFileSync, realpathSync } from "node:fs"; import { relative, resolve, sep } from "node:path"; import { resolveAdapterGlobalSettingsPaths } from "./util/claude-config.js"; // ============================================================================== // Types // ============================================================================== export type PermissionDecision = "allow" | "deny" | "ask"; export interface SecurityPolicy { allow: string[]; deny: string[]; ask: string[]; } // ============================================================================== // Pattern Parsing // ============================================================================== /** * Extract the glob from a Bash permission pattern. * "Bash(sudo *)" returns "sudo *", "Read(.env)" returns null. */ export function parseBashPattern(pattern: string): string | null { // .+ is greedy: for "Bash(echo (foo))" it captures "echo (foo)" // because $ forces the final \) to match only the last paren. const match = pattern.match(/^Bash\((.+)\)$/); return match ? match[1] : null; } /** * Parse any tool permission pattern like "ToolName(glob)". * Returns { tool, glob } or null if not a valid pattern. */ export function parseToolPattern( pattern: string, ): { tool: string; glob: string } | null { // .+ is greedy: for "Read(some(path))" it captures "some(path)" // because $ forces the final \) to match only the last paren. const match = pattern.match(/^(\w+)\((.+)\)$/); return match ? { tool: match[1], glob: match[2] } : null; } // ============================================================================== // Glob-to-Regex Conversion // ============================================================================== /** Escape all regex special characters (including *). */ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\\/\-]/g, "\\$&"); } /** Escape regex specials except *, then convert * to .* */ function convertGlobPart(glob: string): string { return glob .replace(/[.+?^${}()|[\]\\\/\-]/g, "\\$&") .replace(/\*/g, ".*"); } /** * Convert a Bash permission glob to a regex. * * Two formats: * - Colon: "tree:*" becomes /^tree(\s.*)?$/ (command with optional args) * - Space: "sudo *" becomes /^sudo .*$/ (literal glob match) */ export function globToRegex( glob: string, caseInsensitive: boolean = false, ): RegExp { let regexStr: string; const colonIdx = glob.indexOf(":"); if (colonIdx !== -1) { // Colon format: "command:argsGlob" const command = glob.slice(0, colonIdx); const argsGlob = glob.slice(colonIdx + 1); const escapedCmd = escapeRegex(command); const argsRegex = convertGlobPart(argsGlob); // Match command alone OR command + space + args regexStr = `^${escapedCmd}(\\s${argsRegex})?$`; } else { // Plain glob: "sudo *", "ls*", "* commit *" regexStr = `^${convertGlobPart(glob)}$`; } return new RegExp(regexStr, caseInsensitive ? "i" : ""); } /** * Convert a file path glob to a regex. * * Unlike `globToRegex` (which handles command patterns with colon and * space semantics), this handles file path globs where: * - `**` matches any number of path segments (including zero) * - `*` matches anything except path separators * - Paths are matched with forward slashes (callers normalize first) */ export function fileGlobToRegex( glob: string, caseInsensitive: boolean = false, ): RegExp { let regexStr = ""; let i = 0; while (i < glob.length) { // Handle ** (globstar): match any number of directory segments if (glob[i] === "*" && glob[i + 1] === "*") { // **/ at the start or after a slash means "zero or more directories" if (i + 2 < glob.length && glob[i + 2] === "/") { regexStr += "(.*/)?"; i += 3; // skip "*" "*" "/" } else { // Trailing ** matches everything regexStr += ".*"; i += 2; } } else if (glob[i] === "*") { // Single * matches anything except / regexStr += "[^/]*"; i++; } else if (glob[i] === "?") { regexStr += "[^/]"; i++; } else { // Escape regex-special characters regexStr += glob[i].replace(/[.+^${}()|[\]\\\/\-]/g, "\\$&"); i++; } } return new RegExp(`^${regexStr}$`, caseInsensitive ? "i" : ""); } /** * Check if a command matches any Bash pattern in the list. * Returns the matching pattern string, or null. */ export function matchesAnyPattern( command: string, patterns: string[], caseInsensitive: boolean = false, ): string | null { for (const pattern of patterns) { const glob = parseBashPattern(pattern); if (!glob) continue; if (globToRegex(glob, caseInsensitive).test(command)) return pattern; } return null; } // ============================================================================== // Chained Command Splitting & Subshell Extraction // ============================================================================== function isEscaped(command: string, index: number): boolean { let backslashes = 0; for (let i = index - 1; i >= 0 && command[i] === "\\"; i--) { backslashes++; } return backslashes % 2 === 1; } /** * Split a shell command on chain operators (&&, ||, ;, |, \n, \r, &) while * respecting single/double quotes, backticks, subshells, and escape backslashes. * * "echo hello && sudo rm -rf /" → ["echo hello", "sudo rm -rf /"] * * This prevents bypassing deny patterns by prepending innocent commands. */ export function splitChainedCommands(command: string): string[] { const parts: string[] = []; let current = ""; let inSingle = false; let inDouble = false; let inBacktick = false; let dollarParenDepth = 0; for (let i = 0; i < command.length; i++) { const ch = command[i]; const escaped = isEscaped(command, i); if (ch === "'" && !inDouble && !inBacktick && !escaped) { inSingle = !inSingle; current += ch; } else if (ch === '"' && !inSingle && !inBacktick && !escaped) { inDouble = !inDouble; current += ch; } else if (ch === "`" && !inSingle && !inDouble && !escaped) { inBacktick = !inBacktick; current += ch; } else if (!inSingle && !inDouble && !inBacktick) { if (ch === "$" && command[i + 1] === "(" && !escaped) { dollarParenDepth++; current += ch + command[i + 1]; i++; } else if (dollarParenDepth > 0 && ch === "(" && !escaped) { dollarParenDepth++; current += ch; } else if (ch === ")" && dollarParenDepth > 0 && !escaped) { dollarParenDepth--; current += ch; } else if ( dollarParenDepth === 0 && (ch === ";" || ch === "\n" || ch === "\r") && !escaped ) { parts.push(current.trim()); current = ""; } else if (dollarParenDepth === 0 && ch === "|" && command[i + 1] === "|") { parts.push(current.trim()); current = ""; i++; // skip second | } else if (dollarParenDepth === 0 && ch === "&" && command[i + 1] === "&") { parts.push(current.trim()); current = ""; i++; // skip second & } else if (dollarParenDepth === 0 && ch === "&" && !escaped) { parts.push(current.trim()); current = ""; } else if (dollarParenDepth === 0 && ch === "|") { // Single pipe — left side is a command too parts.push(current.trim()); current = ""; } else { current += ch; } } else { current += ch; } } if (current.trim()) parts.push(current.trim()); return parts.filter((p) => p.length > 0); } /** * Recursively extract all nested subshell commands from `$()` and `` `...` ``. * Handles escaping and quote contexts to ensure correct command boundary detection. */ export function extractSubshellCommands(command: string): string[] { const subshells: string[] = []; let inSingle = false; let inDouble = false; let backtickStart = -1; const dollarParenStarts: number[] = []; const dollarParenDepths: number[] = []; let parenDepth = 0; for (let i = 0; i < command.length; i++) { const ch = command[i]; const escaped = isEscaped(command, i); if (ch === "'" && !inDouble && backtickStart === -1 && !escaped) { inSingle = !inSingle; } else if (ch === '"' && !inSingle && backtickStart === -1 && !escaped) { inDouble = !inDouble; } else if (ch === "`" && !inSingle && !inDouble && !escaped) { if (backtickStart === -1) { backtickStart = i + 1; } else { const sub = command.slice(backtickStart, i); subshells.push(sub); subshells.push(...extractSubshellCommands(sub)); backtickStart = -1; } } else if (!inSingle && backtickStart === -1) { if (ch === "$" && command[i + 1] === "(" && !escaped) { if (command[i + 2] === "(") { // Arithmetic expansion is not command execution, but nested command // substitutions inside it still get discovered by the scanner. parenDepth += 2; i += 2; // skip '((' } else { dollarParenStarts.push(i + 2); dollarParenDepths.push(parenDepth); parenDepth++; i++; // skip '(' } } else if (ch === "(" && !escaped) { parenDepth++; } else if (ch === ")" && !escaped) { if (parenDepth > 0) { parenDepth--; } if ( dollarParenDepths.length > 0 && parenDepth === dollarParenDepths[dollarParenDepths.length - 1] ) { dollarParenDepths.pop(); const start = dollarParenStarts.pop()!; const sub = command.slice(start, i); subshells.push(sub); } } } } return subshells; } function collectCommandElements(command: string): string[] { const elements: string[] = []; const segments = splitChainedCommands(command); for (const segment of segments) { elements.push(segment); for (const subshell of extractSubshellCommands(segment)) { elements.push(...collectCommandElements(subshell)); } } return elements; } // ============================================================================== // Settings Reader // ============================================================================== /** Read one settings file and return a SecurityPolicy with only Bash patterns. */ function readSingleSettings(path: string): SecurityPolicy | null { let raw: string; try { raw = readFileSync(path, "utf-8"); } catch { return null; } let parsed: any; try { parsed = JSON.parse(raw); } catch { return null; } const perms = parsed?.permissions; if (!perms || typeof perms !== "object") return null; const filterBash = (arr: unknown): string[] => { if (!Array.isArray(arr)) return []; return arr.filter( (p): p is string => typeof p === "string" && parseBashPattern(p) !== null, ); }; return { allow: filterBash(perms.allow), deny: filterBash(perms.deny), ask: filterBash(perms.ask), }; } /** * Read Bash permission policies from up to 3 settings files. * * Returns policies in precedence order (most local first): * 1. .claude/settings.local.json (project-local) * 2. .claude/settings.json (project-shared) * 3. ~/.claude/settings.json (global) * * Missing or invalid files are silently skipped. */ export function readBashPolicies( projectDir?: string, globalSettingsPath?: string, ): SecurityPolicy[] { const policies: SecurityPolicy[] = []; if (projectDir) { const localPath = resolve(projectDir, ".claude", "settings.local.json"); const localPolicy = readSingleSettings(localPath); if (localPolicy) policies.push(localPolicy); const sharedPath = resolve(projectDir, ".claude", "settings.json"); const sharedPolicy = readSingleSettings(sharedPath); if (sharedPolicy) policies.push(sharedPolicy); } // Issue #451 round-3: read settings from EVERY adapter-specific global path // PLUS the claude global (defense in depth). When the caller passes an // explicit globalSettingsPath we honor it verbatim (back-compat with tests // and callers that already know which file to read). const globalPaths = globalSettingsPath !== undefined ? [globalSettingsPath] : resolveAdapterGlobalSettingsPaths(); for (const globalPath of globalPaths) { const globalPolicy = readSingleSettings(globalPath); if (globalPolicy) policies.push(globalPolicy); } return policies; } /** * Read deny patterns for a specific tool from settings files. * * Reads the same 3-tier settings as `readBashPolicies`, but extracts * only deny globs for the given tool. Used for Read and Grep enforcement * — checks if file paths should be blocked by deny patterns. * * Returns an array of arrays (one per settings file, in precedence order). * Each inner array contains the extracted glob strings. */ export function readToolDenyPatterns( toolName: string, projectDir?: string, globalSettingsPath?: string, ): string[][] { return readToolPermissionPatterns(toolName, "deny", projectDir, globalSettingsPath); } /** * Read `permissions.{deny|allow}` globs for a tool from every settings file in * precedence order (project local → project shared → adapter globals). * * Generalizes the original deny-only reader so the project-boundary guard * (#852) can consult the SAME `permissions.allow` rules the user already * maintains for the host's `Read` tool — instead of inventing a context-mode- * specific opt-out env that would rot into dead code. A user who legitimately * needs an out-of-project read expresses it once, in the host config, e.g. * `"permissions": { "allow": ["Read(/var/log/**)"] }`, and both the host and * context-mode honor it. */ export function readToolPermissionPatterns( toolName: string, kind: "deny" | "allow", projectDir?: string, globalSettingsPath?: string, ): string[][] { const result: string[][] = []; const extractGlobs = (path: string): string[] | null => { let raw: string; try { raw = readFileSync(path, "utf-8"); } catch { return null; } let parsed: any; try { parsed = JSON.parse(raw); } catch { return null; } const entries = parsed?.permissions?.[kind]; if (!Array.isArray(entries)) return []; const globs: string[] = []; for (const entry of entries) { if (typeof entry !== "string") continue; const tp = parseToolPattern(entry); if (tp && tp.tool === toolName) { globs.push(tp.glob); } } return globs; }; if (projectDir) { const localGlobs = extractGlobs( resolve(projectDir, ".claude", "settings.local.json"), ); if (localGlobs !== null) result.push(localGlobs); const sharedGlobs = extractGlobs( resolve(projectDir, ".claude", "settings.json"), ); if (sharedGlobs !== null) result.push(sharedGlobs); } // Issue #451 round-3: union over every adapter-specific global path PLUS // claude global. Each settings file contributes its own globs array entry // so the precedence ordering downstream remains per-file rather than // collapsed. const globalPaths = globalSettingsPath !== undefined ? [globalSettingsPath] : resolveAdapterGlobalSettingsPaths(); for (const globalPath of globalPaths) { const globalGlobs = extractGlobs(globalPath); if (globalGlobs !== null) result.push(globalGlobs); } return result; } // ============================================================================== // Decision Engine // ============================================================================== interface CommandDecision { decision: PermissionDecision; matchedPattern?: string; } /** * Evaluate a command against policies in precedence order. * * Splits chained commands (&&, ||, ;, |) and checks each segment * against deny patterns — prevents bypassing deny by prepending * innocent commands like "echo ok && sudo rm -rf /". * * Within each policy: deny > ask > allow (most restrictive wins). * First definitive match across policies wins. * Default (no match in any policy): "ask". */ export function evaluateCommand( command: string, policies: SecurityPolicy[], caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin", ): CommandDecision { // Extract all main segments and nested subshell commands const allCommands = collectCommandElements(command); // 1. Deny check: If ANY segment or subshell command is denied, block the entire command for (const cmdElement of allCommands) { for (const policy of policies) { const denyMatch = matchesAnyPattern(cmdElement, policy.deny, caseInsensitive); if (denyMatch) return { decision: "deny", matchedPattern: denyMatch }; } } // 2. Allow/Ask check: Evaluate segment-by-segment in precedence order. // The command is allowed if and only if EVERY segment and subshell is explicitly allowed. // If any element matches an ask pattern or matches no allow pattern, it defaults to ask. for (const policy of policies) { let allAllowed = true; let anyAsk = false; let matchedAskPattern: string | undefined; let matchedAllowPattern: string | undefined; for (const cmdElement of allCommands) { const askMatch = matchesAnyPattern(cmdElement, policy.ask, caseInsensitive); if (askMatch) { anyAsk = true; matchedAskPattern = askMatch; break; // Ask wins immediately within this policy } const allowMatch = matchesAnyPattern(cmdElement, policy.allow, caseInsensitive); if (!allowMatch) { allAllowed = false; } else { matchedAllowPattern = allowMatch; } } if (anyAsk) { return { decision: "ask", matchedPattern: matchedAskPattern }; } if (allAllowed && allCommands.length > 0) { return { decision: "allow", matchedPattern: matchedAllowPattern }; } } return { decision: "ask" }; } /** * Server-side variant: only enforce deny patterns. * * The server has no UI for "ask" prompts, so allow/ask patterns are * irrelevant. Returns "deny" if any deny pattern matches, otherwise "allow". * * Also splits chained commands and nested subshells to prevent bypass. */ export function evaluateCommandDenyOnly( command: string, policies: SecurityPolicy[], caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin", ): { decision: "deny" | "allow"; matchedPattern?: string } { const allCommands = collectCommandElements(command); for (const cmdElement of allCommands) { for (const policy of policies) { const denyMatch = matchesAnyPattern(cmdElement, policy.deny, caseInsensitive); if (denyMatch) return { decision: "deny", matchedPattern: denyMatch }; } } return { decision: "allow" }; } // ============================================================================== // File Path Evaluation // ============================================================================== /** * Check if a file path should be denied based on deny globs. * * Normalizes backslashes to forward slashes before matching so that * Windows paths work with Unix-style glob patterns. * * When `projectRoot` is supplied, the path is also matched in its * fully-resolved absolute form **and** — when the file exists — in * its canonical form (`fs.realpathSync`). This prevents two classes * of bypass: * * 1. `..` traversal: a relative path like `../../.ssh/id_rsa` no * longer evades absolute-path deny rules. * 2. Symlink escape: a project-local path whose realpath points * outside the project (e.g. `safe.log -> ~/.ssh/id_rsa`) no * longer evades absolute-path deny rules. * * realpath is best-effort: if the file does not exist yet (ENOENT) * or the syscall fails for any reason, the lexical resolved form is * still checked. This keeps the function usable for paths that will * be created during execution. */ export function evaluateFilePath( filePath: string, denyGlobs: string[][], caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin", projectRoot?: string, ): { denied: boolean; matchedPattern?: string } { const toForward = (path: string): string => path.replace(/\\/g, "/"); // Match against the raw input, the lexically-resolved absolute path, // and the canonical (symlink-resolved) path when the file exists. // Deduplicated so absolute inputs and paths that don't cross symlinks // don't pay the matching cost multiple times. const candidates = new Set(); candidates.add(toForward(filePath)); if (projectRoot) { const lexical = resolve(projectRoot, filePath); candidates.add(toForward(lexical)); try { candidates.add(toForward(realpathSync(lexical))); } catch { // File does not exist yet, or realpath failed — rely on lexical form. } } for (const globs of denyGlobs) { for (const glob of globs) { // Normalize the glob's path separators the same way candidates were // normalized — otherwise a Windows absolute deny rule like // `Read(C:\Users\...\secret.env)` parses with literal backslashes that // never match a forward-slash candidate. const regex = fileGlobToRegex(toForward(glob), caseInsensitive); for (const candidate of candidates) { if (regex.test(candidate)) { return { denied: true, matchedPattern: glob }; } } } } return { denied: false }; } // ============================================================================== // Project-Boundary Containment (Issue #852) // ============================================================================== /** * Pure, algorithmic (no-regex) test: does `filePath` resolve to a location * inside `projectRoot`? * * Issue #852 — `ctx_execute_file` previously fed its `path` argument straight * into `resolve(projectRoot, path)`. Because `path.resolve` lets an *absolute* * argument win outright, an agent could read any file on the host * (`/home/user/secret`, `/etc/passwd`) regardless of the project root, and * `../` traversal escaped just as easily. Claude Code's harness sandbox cannot * inspect MCP input params, so the user approving the MCP call could not see * that the path escaped the workspace. This guard re-anchors the path to the * project boundary. * * Containment is decided on the *resolved* form. When the file (or its parent * chain) exists, the symlink-canonical form is ALSO required to stay inside — * this closes the symlink-escape class (a project-local `safe.log` whose * realpath points at `~/.ssh/id_rsa`), mirroring `evaluateFilePath`. * * A path equal to the project root itself counts as inside. Comparison is * case-insensitive on Windows/macOS to match those filesystems' semantics. * * Returns `true` when `projectRoot` is falsy (no boundary to enforce) so the * caller's fail-open posture is preserved when the root cannot be resolved. */ export function isPathInsideProject( filePath: string, projectRoot: string | undefined, caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin", ): boolean { if (!projectRoot) return true; const root = resolve(projectRoot); const lexical = resolve(projectRoot, filePath); const within = (root: string, candidate: string): boolean => { let a = root; let b = candidate; if (caseInsensitive) { a = a.toLowerCase(); b = b.toLowerCase(); } if (a === b) return true; // `path.relative` is pure string arithmetic — no regex. A candidate inside // the root yields a relative path that neither starts with `..` (escapes // upward) nor is absolute (a different drive/root on Windows that cannot be // expressed relatively). const rel = relative(a, b); if (rel === "") return true; if (rel === ".." || rel.startsWith(".." + sep)) return false; if (isAbsoluteRel(rel)) return false; return true; }; // Lexical containment is the primary gate. if (!within(root, lexical)) return false; // Defense-in-depth: when the path (or a parent) is a symlink that points // outside the project, the canonical form must ALSO stay inside. Best-effort // — a not-yet-created file (ENOENT) falls back to the lexical decision above. try { const canonicalRoot = realpathSync(root); const canonical = realpathSync(lexical); if (!within(canonicalRoot, canonical)) return false; } catch { /* file does not exist yet / realpath failed — lexical decision stands */ } return true; } /** Pure helper: is a `path.relative` result an absolute path? (no regex) */ function isAbsoluteRel(rel: string): boolean { if (rel.startsWith("/")) return true; // POSIX absolute // Windows drive-absolute: "C:\..." or "C:/..." if (rel.length >= 3 && rel[1] === ":" && (rel[2] === "\\" || rel[2] === "/")) { const c = rel.charCodeAt(0); return (c >= 65 && c <= 90) || (c >= 97 && c <= 122); } return false; } /** * Decide whether `filePath` may be processed, given the project boundary AND * the user's existing host `Read(...)` allow rules. * * Decision order: * 1. Inside the project root → allowed (the common case; no config needed). * 2. Outside the project, but matching a `permissions.allow` `Read(...)` glob * the user already configured for the host → allowed. This is the * principled escape hatch: a deliberate out-of-project read is expressed * ONCE in the host config the user already maintains, reusing the same * mechanism Claude Code itself uses to whitelist a path outside the * sandbox — no context-mode-specific opt-out env that would rot into * dead code. * 3. Outside the project, no allow match → denied (closes the #852 escape). * * `allowGlobs` has the same per-settings-file shape as the deny globs returned * by `readToolPermissionPatterns(toolName, "allow", …)`. Allow-matching reuses * `evaluateFilePath` so absolute/`..`/symlink-canonical candidate resolution is * identical to the deny path — one matcher, no divergence. * * Fail-open on an unknown project root (boundary cannot be computed) so the * guard never blocks legitimate in-project work when resolution fails. */ export function evaluateProjectContainment( filePath: string, projectRoot: string | undefined, allowGlobs: string[][] = [], caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin", ): { allowed: boolean; reason: "inside" | "allow-rule" | "outside" } { if (isPathInsideProject(filePath, projectRoot, caseInsensitive)) { return { allowed: true, reason: "inside" }; } // Outside the project — permit only if the user explicitly allowed this path // for the host Read tool. `evaluateFilePath` returns `denied:true` when a glob // MATCHES, so a match here means "explicitly allowed". if (allowGlobs.some((g) => g.length > 0)) { const matched = evaluateFilePath(filePath, allowGlobs, caseInsensitive, projectRoot); if (matched.denied) return { allowed: true, reason: "allow-rule" }; } return { allowed: false, reason: "outside" }; } // ============================================================================== // Shell-Escape Scanner // ============================================================================== // Regex patterns that detect shell-escape calls in non-shell languages. // Each pattern uses capture groups so that the embedded command string // can be extracted from the last non-quote group. // // NOTE: These regexes contain literal strings like "execSync" — they are // patterns for *detecting* shell escapes in user code, not actual usage. const SHELL_ESCAPE_PATTERNS: Record = { python: [ /os\.system\(\s*(['"])(.*?)\1\s*\)/g, /subprocess\.(?:run|call|Popen|check_output|check_call)\(\s*(['"])(.*?)\1/g, ], javascript: [ /exec(?:Sync|File|FileSync)?\(\s*(['"`])(.*?)\1/g, /spawn(?:Sync)?\(\s*(['"`])(.*?)\1/g, ], typescript: [ /exec(?:Sync|File|FileSync)?\(\s*(['"`])(.*?)\1/g, /spawn(?:Sync)?\(\s*(['"`])(.*?)\1/g, ], ruby: [ /system\(\s*(['"])(.*?)\1/g, /`(.*?)`/g, ], go: [ /exec\.Command\(\s*(['"`])(.*?)\1/g, ], php: [ /shell_exec\(\s*(['"`])(.*?)\1/g, /(?:^|[^.])exec\(\s*(['"`])(.*?)\1/g, /(?:^|[^.])system\(\s*(['"`])(.*?)\1/g, /passthru\(\s*(['"`])(.*?)\1/g, /proc_open\(\s*(['"`])(.*?)\1/g, ], rust: [ /Command::new\(\s*(['"`])(.*?)\1/g, ], }; /** * Extract all string elements from a Python subprocess list call. * * subprocess.run(["rm", "-rf", "/"]) → "rm -rf /" * * This catches the list-of-strings form that the single-string regex misses. */ function extractPythonSubprocessListArgs(code: string): string[] { const commands: string[] = []; const pattern = /subprocess\.(?:run|call|Popen|check_output|check_call)\(\s*\[([^\]]+)\]/g; let match: RegExpExecArray | null; while ((match = pattern.exec(code)) !== null) { const listContent = match[1]; const args = [...listContent.matchAll(/(['"])(.*?)\1/g)].map((m) => m[2]); if (args.length > 0) { commands.push(args.join(" ")); } } return commands; } /** * Scan non-shell code for shell-escape calls and extract the embedded * command strings. * * Returns an array of command strings found in the code. For unknown * languages or code without shell-escape calls, returns an empty array. */ export function extractShellCommands( code: string, language: string, ): string[] { const patterns = SHELL_ESCAPE_PATTERNS[language]; if (!patterns && language !== "python") return []; const commands: string[] = []; if (patterns) { for (const pattern of patterns) { // Reset lastIndex since we reuse the global regex pattern.lastIndex = 0; let match: RegExpExecArray | null; while ((match = pattern.exec(code)) !== null) { // The command string is in the last capture group that isn't the // quote delimiter. For patterns with 2 groups (quote + content), // it's group 2. For Ruby backticks with 1 group, it's group 1. const command = match[match.length - 1]; if (command) commands.push(command); } } } // Python: also extract subprocess list-form args if (language === "python") { commands.push(...extractPythonSubprocessListArgs(code)); } return commands; }