#!/usr/bin/env node var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); // node_modules/shell-quote/quote.js var require_quote = __commonJS((exports, module) => { module.exports = function quote(xs) { return xs.map(function(s) { if (s === "") { return "''"; } if (s && typeof s === "object") { return s.op.replace(/(.)/g, "\\$1"); } if (/["\s\\]/.test(s) && !/'/.test(s)) { return "'" + s.replace(/(['])/g, "\\$1") + "'"; } if (/["'\s]/.test(s)) { return '"' + s.replace(/(["\\$`!])/g, "\\$1") + '"'; } return String(s).replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2"); }).join(" "); }; }); // node_modules/shell-quote/parse.js var require_parse = __commonJS((exports, module) => { var CONTROL = "(?:" + [ "\\|\\|", "\\&\\&", ";;", "\\|\\&", "\\<\\(", "\\<\\<\\<", ">>", ">\\&", "<\\&", "[&;()|<>]" ].join("|") + ")"; var controlRE = new RegExp("^" + CONTROL + "$"); var META = "|&;()<> \\t"; var SINGLE_QUOTE = '"((\\\\"|[^"])*?)"'; var DOUBLE_QUOTE = "'((\\\\'|[^'])*?)'"; var hash = /^#$/; var SQ = "'"; var DQ = '"'; var DS = "$"; var TOKEN = ""; var mult = 4294967296; for (i = 0;i < 4; i++) { TOKEN += (mult * Math.random()).toString(16); } var i; var startsWithToken = new RegExp("^" + TOKEN); function matchAll(s, r) { var origIndex = r.lastIndex; var matches = []; var matchObj; while (matchObj = r.exec(s)) { matches.push(matchObj); if (r.lastIndex === matchObj.index) { r.lastIndex += 1; } } r.lastIndex = origIndex; return matches; } function getVar(env, pre, key) { var r = typeof env === "function" ? env(key) : env[key]; if (typeof r === "undefined" && key != "") { r = ""; } else if (typeof r === "undefined") { r = "$"; } if (typeof r === "object") { return pre + TOKEN + JSON.stringify(r) + TOKEN; } return pre + r; } function parseInternal(string, env, opts) { if (!opts) { opts = {}; } var BS = opts.escape || "\\"; var BAREWORD = "(\\" + BS + `['"` + META + `]|[^\\s'"` + META + "])+"; var chunker = new RegExp([ "(" + CONTROL + ")", "(" + BAREWORD + "|" + SINGLE_QUOTE + "|" + DOUBLE_QUOTE + ")+" ].join("|"), "g"); var matches = matchAll(string, chunker); if (matches.length === 0) { return []; } if (!env) { env = {}; } var commented = false; return matches.map(function(match) { var s = match[0]; if (!s || commented) { return; } if (controlRE.test(s)) { return { op: s }; } var quote = false; var esc = false; var out = ""; var isGlob = false; var i2; function parseEnvVar() { i2 += 1; var varend; var varname; var char = s.charAt(i2); if (char === "{") { i2 += 1; if (s.charAt(i2) === "}") { throw new Error("Bad substitution: " + s.slice(i2 - 2, i2 + 1)); } varend = s.indexOf("}", i2); if (varend < 0) { throw new Error("Bad substitution: " + s.slice(i2)); } varname = s.slice(i2, varend); i2 = varend; } else if (/[*@#?$!_-]/.test(char)) { varname = char; i2 += 1; } else { var slicedFromI = s.slice(i2); varend = slicedFromI.match(/[^\w\d_]/); if (!varend) { varname = slicedFromI; i2 = s.length; } else { varname = slicedFromI.slice(0, varend.index); i2 += varend.index - 1; } } return getVar(env, "", varname); } for (i2 = 0;i2 < s.length; i2++) { var c = s.charAt(i2); isGlob = isGlob || !quote && (c === "*" || c === "?"); if (esc) { out += c; esc = false; } else if (quote) { if (c === quote) { quote = false; } else if (quote == SQ) { out += c; } else { if (c === BS) { i2 += 1; c = s.charAt(i2); if (c === DQ || c === BS || c === DS) { out += c; } else { out += BS + c; } } else if (c === DS) { out += parseEnvVar(); } else { out += c; } } } else if (c === DQ || c === SQ) { quote = c; } else if (controlRE.test(c)) { return { op: s }; } else if (hash.test(c)) { commented = true; var commentObj = { comment: string.slice(match.index + i2 + 1) }; if (out.length) { return [out, commentObj]; } return [commentObj]; } else if (c === BS) { esc = true; } else if (c === DS) { out += parseEnvVar(); } else { out += c; } } if (isGlob) { return { op: "glob", pattern: out }; } return out; }).reduce(function(prev, arg) { return typeof arg === "undefined" ? prev : prev.concat(arg); }, []); } module.exports = function parse(s, env, opts) { var mapped = parseInternal(s, env, opts); if (typeof env !== "function") { return mapped; } return mapped.reduce(function(acc, s2) { if (typeof s2 === "object") { return acc.concat(s2); } var xs = s2.split(RegExp("(" + TOKEN + ".*?" + TOKEN + ")", "g")); if (xs.length === 1) { return acc.concat(xs[0]); } return acc.concat(xs.filter(Boolean).map(function(x) { if (startsWithToken.test(x)) { return JSON.parse(x.split(TOKEN)[1]); } return x; })); }, []); }; }); // src/types.ts var MAX_RECURSION_DEPTH = 10; var MAX_STRIP_ITERATIONS = 20; var NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; var COMMAND_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; var MAX_REASON_LENGTH = 256; var SHELL_OPERATORS = new Set(["&&", "||", "|&", "|", "&", ";", ` `]); var SHELL_WRAPPERS = new Set(["bash", "sh", "zsh", "ksh", "dash", "fish", "csh", "tcsh"]); var INTERPRETERS = new Set(["python", "python3", "python2", "node", "ruby", "perl"]); var DANGEROUS_PATTERNS = [ /\brm\s+.*-[rR].*-f\b/, /\brm\s+.*-f.*-[rR]\b/, /\brm\s+-rf\b/, /\brm\s+-fr\b/, /\bgit\s+reset\s+--hard\b/, /\bgit\s+checkout\s+--\b/, /\bgit\s+clean\s+-f\b/, /\bfind\b.*\s-delete\b/ ]; var PARANOID_INTERPRETERS_SUFFIX = ` (Paranoid mode: interpreter one-liners are blocked.)`; // node_modules/shell-quote/index.js var $quote = require_quote(); var $parse = require_parse(); // src/core/shell.ts var ENV_PROXY = new Proxy({}, { get: (_, name) => `$${String(name)}` }); function splitShellCommands(command) { if (hasUnclosedQuotes(command)) { return [[command]]; } const normalizedCommand = command.replace(/\n/g, " ; "); const tokens = $parse(normalizedCommand, ENV_PROXY); const segments = []; let current = []; let i = 0; while (i < tokens.length) { const token = tokens[i]; if (token === undefined) { i++; continue; } if (isOperator(token)) { if (current.length > 0) { segments.push(current); current = []; } i++; continue; } if (typeof token !== "string") { i++; continue; } const nextToken = tokens[i + 1]; if (token === "$" && nextToken && isParenOpen(nextToken)) { if (current.length > 0) { segments.push(current); current = []; } const { innerSegments, endIndex } = extractCommandSubstitution(tokens, i + 2); for (const seg of innerSegments) { segments.push(seg); } i = endIndex + 1; continue; } const backtickSegments = extractBacktickSubstitutions(token); if (backtickSegments.length > 0) { for (const seg of backtickSegments) { segments.push(seg); } } current.push(token); i++; } if (current.length > 0) { segments.push(current); } return segments; } function extractBacktickSubstitutions(token) { const segments = []; let i = 0; while (i < token.length) { const backtickStart = token.indexOf("`", i); if (backtickStart === -1) break; const backtickEnd = token.indexOf("`", backtickStart + 1); if (backtickEnd === -1) break; const innerCommand = token.slice(backtickStart + 1, backtickEnd); if (innerCommand.trim()) { const innerSegments = splitShellCommands(innerCommand); for (const seg of innerSegments) { segments.push(seg); } } i = backtickEnd + 1; } return segments; } function isParenOpen(token) { return typeof token === "object" && token !== null && "op" in token && token.op === "("; } function isParenClose(token) { return typeof token === "object" && token !== null && "op" in token && token.op === ")"; } function extractCommandSubstitution(tokens, startIndex) { const innerSegments = []; let currentSegment = []; let depth = 1; let i = startIndex; while (i < tokens.length && depth > 0) { const token = tokens[i]; if (isParenOpen(token)) { depth++; i++; continue; } if (isParenClose(token)) { depth--; if (depth === 0) break; i++; continue; } if (depth === 1 && token && isOperator(token)) { if (currentSegment.length > 0) { innerSegments.push(currentSegment); currentSegment = []; } i++; continue; } if (typeof token === "string") { currentSegment.push(token); } i++; } if (currentSegment.length > 0) { innerSegments.push(currentSegment); } return { innerSegments, endIndex: i }; } function hasUnclosedQuotes(command) { let inSingle = false; let inDouble = false; let escaped = false; for (const char of command) { if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (char === "'" && !inDouble) { inSingle = !inSingle; } else if (char === '"' && !inSingle) { inDouble = !inDouble; } } return inSingle || inDouble; } var ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/; function parseEnvAssignment(token) { if (!ENV_ASSIGNMENT_RE.test(token)) { return null; } const eqIdx = token.indexOf("="); if (eqIdx < 0) { return null; } return { name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) }; } function stripEnvAssignmentsWithInfo(tokens) { const envAssignments = new Map; let i = 0; while (i < tokens.length) { const token = tokens[i]; if (!token) { break; } const assignment = parseEnvAssignment(token); if (!assignment) { break; } envAssignments.set(assignment.name, assignment.value); i++; } return { tokens: tokens.slice(i), envAssignments }; } function stripWrappers(tokens) { return stripWrappersWithInfo(tokens).tokens; } function stripWrappersWithInfo(tokens) { let result = [...tokens]; const allEnvAssignments = new Map; for (let iteration = 0;iteration < MAX_STRIP_ITERATIONS; iteration++) { const before = result.join(" "); const { tokens: strippedTokens, envAssignments } = stripEnvAssignmentsWithInfo(result); for (const [k, v] of envAssignments) { allEnvAssignments.set(k, v); } result = strippedTokens; if (result.length === 0) break; while (result.length > 0 && result[0]?.includes("=") && !ENV_ASSIGNMENT_RE.test(result[0] ?? "")) { result = result.slice(1); } if (result.length === 0) break; const head = result[0]?.toLowerCase(); if (head !== "sudo" && head !== "env" && head !== "command") { break; } if (head === "sudo") { result = stripSudo(result); } if (head === "env") { const envResult = stripEnvWithInfo(result); result = envResult.tokens; for (const [k, v] of envResult.envAssignments) { allEnvAssignments.set(k, v); } } if (head === "command") { result = stripCommand(result); } if (result.join(" ") === before) break; } const { tokens: finalTokens, envAssignments: finalAssignments } = stripEnvAssignmentsWithInfo(result); for (const [k, v] of finalAssignments) { allEnvAssignments.set(k, v); } return { tokens: finalTokens, envAssignments: allEnvAssignments }; } var SUDO_OPTS_WITH_VALUE = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]); function stripSudo(tokens) { let i = 1; while (i < tokens.length) { const token = tokens[i]; if (!token) break; if (token === "--") { return tokens.slice(i + 1); } if (!token.startsWith("-")) { break; } if (SUDO_OPTS_WITH_VALUE.has(token)) { i += 2; continue; } i++; } return tokens.slice(i); } var ENV_OPTS_NO_VALUE = new Set(["-i", "-0", "--null"]); var ENV_OPTS_WITH_VALUE = new Set([ "-u", "--unset", "-C", "--chdir", "-S", "--split-string", "-P" ]); function stripEnvWithInfo(tokens) { const envAssignments = new Map; let i = 1; while (i < tokens.length) { const token = tokens[i]; if (!token) break; if (token === "--") { return { tokens: tokens.slice(i + 1), envAssignments }; } if (ENV_OPTS_NO_VALUE.has(token)) { i++; continue; } if (ENV_OPTS_WITH_VALUE.has(token)) { i += 2; continue; } if (token.startsWith("-u=") || token.startsWith("--unset=")) { i++; continue; } if (token.startsWith("-C=") || token.startsWith("--chdir=")) { i++; continue; } if (token.startsWith("-P")) { i++; continue; } if (token.startsWith("-")) { i++; continue; } const assignment = parseEnvAssignment(token); if (!assignment) { break; } envAssignments.set(assignment.name, assignment.value); i++; } return { tokens: tokens.slice(i), envAssignments }; } function stripCommand(tokens) { let i = 1; while (i < tokens.length) { const token = tokens[i]; if (!token) break; if (token === "-p" || token === "-v" || token === "-V") { i++; continue; } if (token === "--") { return tokens.slice(i + 1); } if (token.startsWith("-") && !token.startsWith("--") && token.length > 1) { const chars = token.slice(1); if (!/^[pvV]+$/.test(chars)) { break; } i++; continue; } break; } return tokens.slice(i); } function extractShortOpts(tokens) { const opts = new Set; let pastDoubleDash = false; for (const token of tokens) { if (token === "--") { pastDoubleDash = true; continue; } if (pastDoubleDash) continue; if (token.startsWith("-") && !token.startsWith("--") && token.length > 1) { for (let i = 1;i < token.length; i++) { const char = token[i]; if (!char || !/[a-zA-Z]/.test(char)) { break; } opts.add(`-${char}`); } } } return opts; } function normalizeCommandToken(token) { return getBasename(token).toLowerCase(); } function getBasename(token) { return token.includes("/") ? token.split("/").pop() ?? token : token; } function isOperator(token) { return typeof token === "object" && token !== null && "op" in token && SHELL_OPERATORS.has(token.op); } // src/core/analyze/dangerous-text.ts function dangerousInText(text) { const t = text.toLowerCase(); const stripped = t.trimStart(); const isEchoOrRg = stripped.startsWith("echo ") || stripped.startsWith("rg "); const patterns = [ { regex: /\brm\s+(-[^\s]*r[^\s]*\s+-[^\s]*f|-[^\s]*f[^\s]*\s+-[^\s]*r|-[^\s]*rf|-[^\s]*fr)\b/, reason: "rm -rf" }, { regex: /\bgit\s+reset\s+--hard\b/, reason: "git reset --hard" }, { regex: /\bgit\s+reset\s+--merge\b/, reason: "git reset --merge" }, { regex: /\bgit\s+clean\s+(-[^\s]*f|-f)\b/, reason: "git clean -f" }, { regex: /\bgit\s+push\s+[^|;]*(-f\b|--force\b)(?!-with-lease)/, reason: "git push --force (use --force-with-lease instead)" }, { regex: /\bgit\s+branch\s+-D\b/, reason: "git branch -D", caseSensitive: true }, { regex: /\bgit\s+stash\s+(drop|clear)\b/, reason: "git stash drop/clear" }, { regex: /\bgit\s+checkout\s+--\s/, reason: "git checkout --" }, { regex: /\bgit\s+restore\b(?!.*--(staged|help))/, reason: "git restore (without --staged)" }, { regex: /\bfind\b[^\n;|&]*\s-delete\b/, reason: "find -delete", skipForEchoRg: true } ]; for (const { regex, reason, skipForEchoRg, caseSensitive } of patterns) { if (skipForEchoRg && isEchoOrRg) continue; const target = caseSensitive ? text : t; if (regex.test(target)) { return reason; } } return null; } // src/core/rules-custom.ts function checkCustomRules(tokens, rules) { if (tokens.length === 0 || rules.length === 0) { return null; } const command = getBasename(tokens[0] ?? ""); const subcommand = extractSubcommand(tokens); const shortOpts = extractShortOpts(tokens); for (const rule of rules) { if (!matchesCommand(command, rule.command)) { continue; } if (rule.subcommand && subcommand !== rule.subcommand) { continue; } if (matchesBlockArgs(tokens, rule.block_args, shortOpts)) { return `[${rule.name}] ${rule.reason}`; } } return null; } function matchesCommand(command, ruleCommand) { return command === ruleCommand; } var OPTIONS_WITH_VALUES = new Set([ "-c", "-C", "--git-dir", "--work-tree", "--namespace", "--config-env" ]); function extractSubcommand(tokens) { let skipNext = false; for (let i = 1;i < tokens.length; i++) { const token = tokens[i]; if (!token) continue; if (skipNext) { skipNext = false; continue; } if (token === "--") { const nextToken = tokens[i + 1]; if (nextToken && !nextToken.startsWith("-")) { return nextToken; } return null; } if (OPTIONS_WITH_VALUES.has(token)) { skipNext = true; continue; } if (token.startsWith("-")) { for (const opt of OPTIONS_WITH_VALUES) { if (token.startsWith(`${opt}=`)) { break; } } continue; } return token; } return null; } function matchesBlockArgs(tokens, blockArgs, shortOpts) { const blockArgsSet = new Set(blockArgs); for (const token of tokens) { if (blockArgsSet.has(token)) { return true; } } for (const opt of shortOpts) { if (blockArgsSet.has(opt)) { return true; } } return false; } // src/core/rules-git.ts var REASON_CHECKOUT_DOUBLE_DASH = "git checkout -- discards uncommitted changes permanently. Use 'git stash' first."; var REASON_CHECKOUT_REF_PATH = "git checkout -- overwrites working tree with ref version. Use 'git stash' first."; var REASON_CHECKOUT_PATHSPEC_FROM_FILE = "git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first."; var REASON_CHECKOUT_AMBIGUOUS = "git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files."; var REASON_RESTORE = "git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage."; var REASON_RESTORE_WORKTREE = "git restore --worktree explicitly discards working tree changes. Use 'git stash' first."; var REASON_RESET_HARD = "git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first."; var REASON_RESET_MERGE = "git reset --merge can lose uncommitted changes. Use 'git stash' first."; var REASON_CLEAN = "git clean -f removes untracked files permanently. Use 'git clean -n' to preview first."; var REASON_PUSH_FORCE = "git push --force destroys remote history. Use --force-with-lease for safer force push."; var REASON_BRANCH_DELETE = "git branch -D force-deletes without merge check. Use -d for safe delete."; var REASON_STASH_DROP = "git stash drop permanently deletes stashed changes. Consider 'git stash list' first."; var REASON_STASH_CLEAR = "git stash clear deletes ALL stashed changes permanently."; var REASON_WORKTREE_REMOVE_FORCE = "git worktree remove --force can delete uncommitted changes. Remove --force flag."; var GIT_GLOBAL_OPTS_WITH_VALUE = new Set([ "-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env" ]); var CHECKOUT_OPTS_WITH_VALUE = new Set([ "-b", "-B", "--orphan", "--conflict", "--pathspec-from-file", "--unified" ]); var CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(["--recurse-submodules", "--track", "-t"]); var CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([ "-q", "--quiet", "-f", "--force", "-d", "--detach", "-m", "--merge", "-p", "--patch", "--ours", "--theirs", "--no-track", "--overwrite-ignore", "--no-overwrite-ignore", "--ignore-other-worktrees", "--progress", "--no-progress" ]); function splitAtDoubleDash(tokens) { const index = tokens.indexOf("--"); if (index === -1) { return { index: -1, before: tokens, after: [] }; } return { index, before: tokens.slice(0, index), after: tokens.slice(index + 1) }; } function analyzeGit(tokens) { const { subcommand, rest } = extractGitSubcommandAndRest(tokens); if (!subcommand) { return null; } switch (subcommand.toLowerCase()) { case "checkout": return analyzeGitCheckout(rest); case "restore": return analyzeGitRestore(rest); case "reset": return analyzeGitReset(rest); case "clean": return analyzeGitClean(rest); case "push": return analyzeGitPush(rest); case "branch": return analyzeGitBranch(rest); case "stash": return analyzeGitStash(rest); case "worktree": return analyzeGitWorktree(rest); default: return null; } } function extractGitSubcommandAndRest(tokens) { if (tokens.length === 0) { return { subcommand: null, rest: [] }; } const firstToken = tokens[0]; const command = firstToken ? getBasename(firstToken).toLowerCase() : null; if (command !== "git") { return { subcommand: null, rest: [] }; } let i = 1; while (i < tokens.length) { const token = tokens[i]; if (!token) break; if (token === "--") { const nextToken = tokens[i + 1]; if (nextToken && !nextToken.startsWith("-")) { return { subcommand: nextToken, rest: tokens.slice(i + 2) }; } return { subcommand: null, rest: tokens.slice(i + 1) }; } if (token.startsWith("-")) { if (GIT_GLOBAL_OPTS_WITH_VALUE.has(token)) { i += 2; } else if (token.startsWith("-c") && token.length > 2) { i++; } else if (token.startsWith("-C") && token.length > 2) { i++; } else { i++; } } else { return { subcommand: token, rest: tokens.slice(i + 1) }; } } return { subcommand: null, rest: [] }; } function analyzeGitCheckout(tokens) { const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens); for (const token of tokens) { if (token === "-b" || token === "-B" || token === "--orphan") { return null; } if (token === "--pathspec-from-file") { return REASON_CHECKOUT_PATHSPEC_FROM_FILE; } if (token.startsWith("--pathspec-from-file=")) { return REASON_CHECKOUT_PATHSPEC_FROM_FILE; } } if (doubleDashIdx !== -1) { const hasRefBeforeDash = beforeDash.some((t) => !t.startsWith("-")); if (hasRefBeforeDash) { return REASON_CHECKOUT_REF_PATH; } return REASON_CHECKOUT_DOUBLE_DASH; } const positionalArgs = getCheckoutPositionalArgs(tokens); if (positionalArgs.length >= 2) { return REASON_CHECKOUT_AMBIGUOUS; } return null; } function getCheckoutPositionalArgs(tokens) { const positional = []; let i = 0; while (i < tokens.length) { const token = tokens[i]; if (!token) break; if (token === "--") { break; } if (token.startsWith("-")) { if (CHECKOUT_OPTS_WITH_VALUE.has(token)) { i += 2; } else if (token.startsWith("--") && token.includes("=")) { i++; } else if (CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)) { const nextToken = tokens[i + 1]; if (nextToken && !nextToken.startsWith("-") && (token === "--recurse-submodules" || token === "--track" || token === "-t")) { const validModes = token === "--recurse-submodules" ? ["checkout", "on-demand"] : ["direct", "inherit"]; if (validModes.includes(nextToken)) { i += 2; } else { i++; } } else { i++; } } else if (token.startsWith("--") && !CHECKOUT_KNOWN_OPTS_NO_VALUE.has(token) && !CHECKOUT_OPTS_WITH_VALUE.has(token) && !CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)) { const nextToken = tokens[i + 1]; if (nextToken && !nextToken.startsWith("-")) { i += 2; } else { i++; } } else { i++; } } else { positional.push(token); i++; } } return positional; } function analyzeGitRestore(tokens) { let hasStaged = false; for (const token of tokens) { if (token === "--help" || token === "--version") { return null; } if (token === "--worktree" || token === "-W") { return REASON_RESTORE_WORKTREE; } if (token === "--staged" || token === "-S") { hasStaged = true; } } return hasStaged ? null : REASON_RESTORE; } function analyzeGitReset(tokens) { for (const token of tokens) { if (token === "--hard") { return REASON_RESET_HARD; } if (token === "--merge") { return REASON_RESET_MERGE; } } return null; } function analyzeGitClean(tokens) { for (const token of tokens) { if (token === "-n" || token === "--dry-run") { return null; } } const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--")); if (tokens.includes("--force") || shortOpts.has("-f")) { return REASON_CLEAN; } return null; } function analyzeGitPush(tokens) { let hasForceWithLease = false; const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--")); const hasForce = tokens.includes("--force") || shortOpts.has("-f"); for (const token of tokens) { if (token === "--force-with-lease" || token.startsWith("--force-with-lease=")) { hasForceWithLease = true; } } if (hasForce && !hasForceWithLease) { return REASON_PUSH_FORCE; } return null; } function analyzeGitBranch(tokens) { const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--")); if (shortOpts.has("-D")) { return REASON_BRANCH_DELETE; } return null; } function analyzeGitStash(tokens) { for (const token of tokens) { if (token === "drop") { return REASON_STASH_DROP; } if (token === "clear") { return REASON_STASH_CLEAR; } } return null; } function analyzeGitWorktree(tokens) { const hasRemove = tokens.includes("remove"); if (!hasRemove) return null; const { before } = splitAtDoubleDash(tokens); for (const token of before) { if (token === "--force" || token === "-f") { return REASON_WORKTREE_REMOVE_FORCE; } } return null; } // src/core/rules-rm.ts import { realpathSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { normalize, resolve } from "node:path"; // src/core/analyze/rm-flags.ts function hasRecursiveForceFlags(tokens) { let hasRecursive = false; let hasForce = false; for (const token of tokens) { if (token === "--") break; if (token === "-r" || token === "-R" || token === "--recursive") { hasRecursive = true; } else if (token === "-f" || token === "--force") { hasForce = true; } else if (token.startsWith("-") && !token.startsWith("--")) { if (token.includes("r") || token.includes("R")) hasRecursive = true; if (token.includes("f")) hasForce = true; } } return hasRecursive && hasForce; } // src/core/rules-rm.ts var REASON_RM_RF = "rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually."; var REASON_RM_RF_ROOT_HOME = "rm -rf targeting root or home directory is extremely dangerous and always blocked."; function analyzeRm(tokens, options = {}) { const { cwd, originalCwd, paranoid = false, allowTmpdirVar = true, tmpdirOverridden = false } = options; const anchoredCwd = originalCwd ?? cwd ?? null; const resolvedCwd = cwd ?? null; const trustTmpdirVar = allowTmpdirVar && !tmpdirOverridden; const ctx = { anchoredCwd, resolvedCwd, paranoid, trustTmpdirVar, homeDir: getHomeDirForRmPolicy() }; if (!hasRecursiveForceFlags(tokens)) { return null; } const targets = extractTargets(tokens); for (const target of targets) { const classification = classifyTarget(target, ctx); const reason = reasonForClassification(classification, ctx); if (reason) { return reason; } } return null; } function extractTargets(tokens) { const targets = []; let pastDoubleDash = false; for (let i = 1;i < tokens.length; i++) { const token = tokens[i]; if (!token) continue; if (token === "--") { pastDoubleDash = true; continue; } if (pastDoubleDash) { targets.push(token); continue; } if (!token.startsWith("-")) { targets.push(token); } } return targets; } function classifyTarget(target, ctx) { if (isDangerousRootOrHomeTarget(target)) { return { kind: "root_or_home_target" }; } const anchoredCwd = ctx.anchoredCwd; if (anchoredCwd) { if (isCwdSelfTarget(target, anchoredCwd)) { return { kind: "cwd_self_target" }; } } if (isTempTarget(target, ctx.trustTmpdirVar)) { return { kind: "temp_target" }; } if (anchoredCwd) { if (isCwdHomeForRmPolicy(anchoredCwd, ctx.homeDir)) { return { kind: "root_or_home_target" }; } if (isTargetWithinCwd(target, anchoredCwd, ctx.resolvedCwd ?? anchoredCwd)) { return { kind: "within_anchored_cwd" }; } } return { kind: "outside_anchored_cwd" }; } function reasonForClassification(classification, ctx) { switch (classification.kind) { case "root_or_home_target": return REASON_RM_RF_ROOT_HOME; case "cwd_self_target": return REASON_RM_RF; case "temp_target": return null; case "within_anchored_cwd": if (ctx.paranoid) { return `${REASON_RM_RF} (SAFETY_NET_PARANOID_RM enabled)`; } return null; case "outside_anchored_cwd": return REASON_RM_RF; } } function isDangerousRootOrHomeTarget(path) { const normalized = path.trim(); if (normalized === "/" || normalized === "/*") { return true; } if (normalized === "~" || normalized === "~/" || normalized.startsWith("~/")) { if (normalized === "~" || normalized === "~/" || normalized === "~/*") { return true; } } if (normalized === "$HOME" || normalized === "$HOME/" || normalized === "$HOME/*") { return true; } if (normalized === "${HOME}" || normalized === "${HOME}/" || normalized === "${HOME}/*") { return true; } return false; } function isTempTarget(path, allowTmpdirVar) { const normalized = path.trim(); if (normalized.includes("..")) { return false; } if (normalized === "/tmp" || normalized.startsWith("/tmp/")) { return true; } if (normalized === "/var/tmp" || normalized.startsWith("/var/tmp/")) { return true; } const systemTmpdir = tmpdir(); if (normalized.startsWith(`${systemTmpdir}/`) || normalized === systemTmpdir) { return true; } if (allowTmpdirVar) { if (normalized === "$TMPDIR" || normalized.startsWith("$TMPDIR/")) { return true; } if (normalized === "${TMPDIR}" || normalized.startsWith("${TMPDIR}/")) { return true; } } return false; } function getHomeDirForRmPolicy() { return process.env.HOME ?? homedir(); } function isCwdHomeForRmPolicy(cwd, homeDir) { try { const normalizedCwd = normalize(cwd); const normalizedHome = normalize(homeDir); return normalizedCwd === normalizedHome; } catch { return false; } } function isCwdSelfTarget(target, cwd) { if (target === "." || target === "./") { return true; } try { const resolved = resolve(cwd, target); const realCwd = realpathSync(cwd); const realResolved = realpathSync(resolved); return realResolved === realCwd; } catch { try { const resolved = resolve(cwd, target); const normalizedCwd = normalize(cwd); return resolved === normalizedCwd; } catch { return false; } } } function isTargetWithinCwd(target, originalCwd, effectiveCwd) { const resolveCwd = effectiveCwd ?? originalCwd; if (target.startsWith("~") || target.startsWith("$HOME") || target.startsWith("${HOME}")) { return false; } if (target.includes("$") || target.includes("`")) { return false; } if (target.startsWith("/")) { try { const normalizedTarget = normalize(target); const normalizedCwd = `${normalize(originalCwd)}/`; return normalizedTarget.startsWith(normalizedCwd); } catch { return false; } } if (target.startsWith("./") || !target.includes("/")) { try { const resolved = resolve(resolveCwd, target); const normalizedOriginalCwd = normalize(originalCwd); return resolved.startsWith(`${normalizedOriginalCwd}/`) || resolved === normalizedOriginalCwd; } catch { return false; } } if (target.startsWith("../")) { return false; } try { const resolved = resolve(resolveCwd, target); const normalizedCwd = normalize(originalCwd); return resolved.startsWith(`${normalizedCwd}/`) || resolved === normalizedCwd; } catch { return false; } } function isHomeDirectory(cwd) { const home = process.env.HOME ?? homedir(); try { const normalizedCwd = normalize(cwd); const normalizedHome = normalize(home); return normalizedCwd === normalizedHome; } catch { return false; } } // src/core/analyze/constants.ts var DISPLAY_COMMANDS = new Set([ "echo", "printf", "cat", "head", "tail", "less", "more", "grep", "rg", "ag", "ack", "sed", "awk", "cut", "tr", "sort", "uniq", "wc", "tee", "man", "help", "info", "type", "which", "whereis", "whatis", "apropos", "file", "stat", "ls", "ll", "dir", "tree", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "hostname", "uname", "env", "printenv", "set", "export", "alias", "history", "jobs", "fg", "bg", "test", "true", "false", "read", "return", "exit", "break", "continue", "shift", "wait", "trap", "basename", "dirname", "realpath", "readlink", "md5sum", "sha256sum", "base64", "xxd", "od", "hexdump", "strings", "diff", "cmp", "comm", "join", "paste", "column", "fmt", "fold", "nl", "pr", "expand", "unexpand", "rev", "tac", "shuf", "seq", "yes", "timeout", "time", "sleep", "watch", "logger", "write", "wall", "mesg", "notify-send" ]); // src/core/analyze/find.ts var REASON_FIND_DELETE = "find -delete permanently removes files. Use -print first to preview."; function analyzeFind(tokens) { if (findHasDelete(tokens.slice(1))) { return REASON_FIND_DELETE; } for (let i = 0;i < tokens.length; i++) { const token = tokens[i]; if (token === "-exec" || token === "-execdir") { const execTokens = tokens.slice(i + 1); const semicolonIdx = execTokens.indexOf(";"); const plusIdx = execTokens.indexOf("+"); const endIdx = semicolonIdx !== -1 && plusIdx !== -1 ? Math.min(semicolonIdx, plusIdx) : semicolonIdx !== -1 ? semicolonIdx : plusIdx !== -1 ? plusIdx : execTokens.length; let execCommand = execTokens.slice(0, endIdx); execCommand = stripWrappers(execCommand); if (execCommand.length > 0) { let head = getBasename(execCommand[0] ?? ""); if (head === "busybox" && execCommand.length > 1) { execCommand = execCommand.slice(1); head = getBasename(execCommand[0] ?? ""); } if (head === "rm" && hasRecursiveForceFlags(execCommand)) { return "find -exec rm -rf is dangerous. Use explicit file list instead."; } } } } return null; } function findHasDelete(tokens) { let i = 0; let insideExec = false; let execDepth = 0; while (i < tokens.length) { const token = tokens[i]; if (!token) { i++; continue; } if (token === "-exec" || token === "-execdir") { insideExec = true; execDepth++; i++; continue; } if (insideExec && (token === ";" || token === "+")) { execDepth--; if (execDepth === 0) { insideExec = false; } i++; continue; } if (insideExec) { i++; continue; } if (token === "-name" || token === "-iname" || token === "-path" || token === "-ipath" || token === "-regex" || token === "-iregex" || token === "-type" || token === "-user" || token === "-group" || token === "-perm" || token === "-size" || token === "-mtime" || token === "-ctime" || token === "-atime" || token === "-newer" || token === "-printf" || token === "-fprint" || token === "-fprintf") { i += 2; continue; } if (token === "-delete") { return true; } i++; } return false; } // src/core/analyze/interpreters.ts function extractInterpreterCodeArg(tokens) { for (let i = 1;i < tokens.length; i++) { const token = tokens[i]; if (!token) continue; if ((token === "-c" || token === "-e") && tokens[i + 1]) { return tokens[i + 1] ?? null; } } return null; } function containsDangerousCode(code) { for (const pattern of DANGEROUS_PATTERNS) { if (pattern.test(code)) { return true; } } return false; } // src/core/analyze/shell-wrappers.ts function extractDashCArg(tokens) { for (let i = 1;i < tokens.length; i++) { const token = tokens[i]; if (!token) continue; if (token === "-c" && tokens[i + 1]) { return tokens[i + 1] ?? null; } if (token.startsWith("-") && token.includes("c") && !token.startsWith("--")) { const nextToken = tokens[i + 1]; if (nextToken && !nextToken.startsWith("-")) { return nextToken; } } } return null; } // src/core/analyze/parallel.ts var REASON_PARALLEL_RM = "parallel rm -rf with dynamic input is dangerous. Use explicit file list instead."; var REASON_PARALLEL_SHELL = "parallel with shell -c can execute arbitrary commands from dynamic input."; function analyzeParallel(tokens, context) { const parseResult = parseParallelCommand(tokens); if (!parseResult) { return null; } const { template, args, hasPlaceholder } = parseResult; if (template.length === 0) { for (const arg of args) { const reason = context.analyzeNested(arg); if (reason) { return reason; } } return null; } let childTokens = stripWrappers([...template]); let head = getBasename(childTokens[0] ?? "").toLowerCase(); if (head === "busybox" && childTokens.length > 1) { childTokens = childTokens.slice(1); head = getBasename(childTokens[0] ?? "").toLowerCase(); } if (SHELL_WRAPPERS.has(head)) { const dashCArg = extractDashCArg(childTokens); if (dashCArg) { if (dashCArg === "{}" || dashCArg === "{1}") { return REASON_PARALLEL_SHELL; } if (dashCArg.includes("{}")) { if (args.length > 0) { for (const arg of args) { const expandedScript = dashCArg.replace(/{}/g, arg); const reason3 = context.analyzeNested(expandedScript); if (reason3) { return reason3; } } return null; } const reason2 = context.analyzeNested(dashCArg); if (reason2) { return reason2; } return null; } const reason = context.analyzeNested(dashCArg); if (reason) { return reason; } if (hasPlaceholder) { return REASON_PARALLEL_SHELL; } return null; } if (args.length > 0) { return REASON_PARALLEL_SHELL; } if (hasPlaceholder) { return REASON_PARALLEL_SHELL; } return null; } if (head === "rm" && hasRecursiveForceFlags(childTokens)) { if (hasPlaceholder && args.length > 0) { for (const arg of args) { const expandedTokens = childTokens.map((t) => t.replace(/{}/g, arg)); const rmResult = analyzeRm(expandedTokens, { cwd: context.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar }); if (rmResult) { return rmResult; } } return null; } if (args.length > 0) { const expandedTokens = [...childTokens, args[0] ?? ""]; const rmResult = analyzeRm(expandedTokens, { cwd: context.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar }); if (rmResult) { return rmResult; } return null; } return REASON_PARALLEL_RM; } if (head === "find") { const findResult = analyzeFind(childTokens); if (findResult) { return findResult; } } if (head === "git") { const gitResult = analyzeGit(childTokens); if (gitResult) { return gitResult; } } return null; } function parseParallelCommand(tokens) { const parallelOptsWithValue = new Set([ "-S", "--sshlogin", "--slf", "--sshloginfile", "-a", "--arg-file", "--colsep", "-I", "--replace", "--results", "--result", "--res" ]); let i = 1; const templateTokens = []; let markerIndex = -1; while (i < tokens.length) { const token = tokens[i]; if (!token) break; if (token === ":::") { markerIndex = i; break; } if (token === "--") { i++; while (i < tokens.length) { const token2 = tokens[i]; if (token2 === undefined || token2 === ":::") break; templateTokens.push(token2); i++; } if (i < tokens.length && tokens[i] === ":::") { markerIndex = i; } break; } if (token.startsWith("-")) { if (token.startsWith("-j") && token.length > 2 && /^\d+$/.test(token.slice(2))) { i++; continue; } if (token.startsWith("--") && token.includes("=")) { i++; continue; } if (parallelOptsWithValue.has(token)) { i += 2; continue; } if (token === "-j" || token === "--jobs") { i += 2; continue; } i++; } else { while (i < tokens.length) { const token2 = tokens[i]; if (token2 === undefined || token2 === ":::") break; templateTokens.push(token2); i++; } if (i < tokens.length && tokens[i] === ":::") { markerIndex = i; } break; } } const args = []; if (markerIndex !== -1) { for (let j = markerIndex + 1;j < tokens.length; j++) { const token = tokens[j]; if (token && token !== ":::") { args.push(token); } } } const hasPlaceholder = templateTokens.some((t) => t.includes("{}") || t.includes("{1}") || t.includes("{.}")); if (templateTokens.length === 0 && markerIndex === -1) { return null; } return { template: templateTokens, args, hasPlaceholder }; } // src/core/analyze/tmpdir.ts import { tmpdir as tmpdir2 } from "node:os"; function isTmpdirOverriddenToNonTemp(envAssignments) { if (!envAssignments.has("TMPDIR")) { return false; } const tmpdirValue = envAssignments.get("TMPDIR") ?? ""; if (tmpdirValue === "") { return true; } const sysTmpdir = tmpdir2(); if (isPathOrSubpath(tmpdirValue, "/tmp") || isPathOrSubpath(tmpdirValue, "/var/tmp") || isPathOrSubpath(tmpdirValue, sysTmpdir)) { return false; } return true; } function isPathOrSubpath(path, basePath) { if (path === basePath) { return true; } const baseWithSlash = basePath.endsWith("/") ? basePath : `${basePath}/`; return path.startsWith(baseWithSlash); } // src/core/analyze/xargs.ts var REASON_XARGS_RM = "xargs rm -rf with dynamic input is dangerous. Use explicit file list instead."; var REASON_XARGS_SHELL = "xargs with shell -c can execute arbitrary commands from dynamic input."; function analyzeXargs(tokens, context) { const { childTokens: rawChildTokens } = extractXargsChildCommandWithInfo(tokens); let childTokens = stripWrappers(rawChildTokens); if (childTokens.length === 0) { return null; } let head = getBasename(childTokens[0] ?? "").toLowerCase(); if (head === "busybox" && childTokens.length > 1) { childTokens = childTokens.slice(1); head = getBasename(childTokens[0] ?? "").toLowerCase(); } if (SHELL_WRAPPERS.has(head)) { return REASON_XARGS_SHELL; } if (head === "rm" && hasRecursiveForceFlags(childTokens)) { const rmResult = analyzeRm(childTokens, { cwd: context.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar }); if (rmResult) { return rmResult; } return REASON_XARGS_RM; } if (head === "find") { const findResult = analyzeFind(childTokens); if (findResult) { return findResult; } } if (head === "git") { const gitResult = analyzeGit(childTokens); if (gitResult) { return gitResult; } } return null; } function extractXargsChildCommandWithInfo(tokens) { const xargsOptsWithValue = new Set([ "-L", "-n", "-P", "-s", "-a", "-E", "-e", "-d", "-J", "--max-args", "--max-procs", "--max-chars", "--arg-file", "--eof", "--delimiter", "--max-lines" ]); let replacementToken = null; let i = 1; while (i < tokens.length) { const token = tokens[i]; if (!token) break; if (token === "--") { return { childTokens: [...tokens.slice(i + 1)], replacementToken }; } if (token.startsWith("-")) { if (token === "-I") { replacementToken = tokens[i + 1] ?? "{}"; i += 2; continue; } if (token.startsWith("-I") && token.length > 2) { replacementToken = token.slice(2); i++; continue; } if (token === "--replace") { replacementToken = "{}"; i++; continue; } if (token.startsWith("--replace=")) { const value = token.slice("--replace=".length); replacementToken = value === "" ? "{}" : value; i++; continue; } if (token === "-J") { i += 2; continue; } if (xargsOptsWithValue.has(token)) { i += 2; } else if (token.startsWith("--") && token.includes("=")) { i++; } else if (token.startsWith("-L") || token.startsWith("-n") || token.startsWith("-P") || token.startsWith("-s")) { i++; } else { i++; } } else { return { childTokens: [...tokens.slice(i)], replacementToken }; } } return { childTokens: [], replacementToken }; } // src/core/analyze/segment.ts var REASON_INTERPRETER_DANGEROUS = "Detected potentially dangerous command in interpreter code."; var REASON_INTERPRETER_BLOCKED = "Interpreter one-liners are blocked in paranoid mode."; var REASON_RM_HOME_CWD = "rm -rf in home directory is dangerous. Change to a project directory first."; function deriveCwdContext(options) { const cwdUnknown = options.effectiveCwd === null; const cwdForRm = cwdUnknown ? undefined : options.effectiveCwd ?? options.cwd; const originalCwd = cwdUnknown ? undefined : options.cwd; return { cwdUnknown, cwdForRm, originalCwd }; } function analyzeSegment(tokens, depth, options) { if (tokens.length === 0) { return null; } const { tokens: strippedEnv, envAssignments: leadingEnvAssignments } = stripEnvAssignmentsWithInfo(tokens); const { tokens: stripped, envAssignments: wrapperEnvAssignments } = stripWrappersWithInfo(strippedEnv); const envAssignments = new Map(leadingEnvAssignments); for (const [k, v] of wrapperEnvAssignments) { envAssignments.set(k, v); } if (stripped.length === 0) { return null; } const head = stripped[0]; if (!head) { return null; } const normalizedHead = normalizeCommandToken(head); const basename = getBasename(head); const { cwdForRm, originalCwd } = deriveCwdContext(options); const allowTmpdirVar = !isTmpdirOverriddenToNonTemp(envAssignments); if (SHELL_WRAPPERS.has(normalizedHead)) { const dashCArg = extractDashCArg(stripped); if (dashCArg) { return options.analyzeNested(dashCArg); } } if (INTERPRETERS.has(normalizedHead)) { const codeArg = extractInterpreterCodeArg(stripped); if (codeArg) { if (options.paranoidInterpreters) { return REASON_INTERPRETER_BLOCKED + PARANOID_INTERPRETERS_SUFFIX; } const innerReason = options.analyzeNested(codeArg); if (innerReason) { return innerReason; } if (containsDangerousCode(codeArg)) { return REASON_INTERPRETER_DANGEROUS; } } } if (normalizedHead === "busybox" && stripped.length > 1) { return analyzeSegment(stripped.slice(1), depth, options); } const isGit = basename.toLowerCase() === "git"; const isRm = basename === "rm"; const isFind = basename === "find"; const isXargs = basename === "xargs"; const isParallel = basename === "parallel"; if (isGit) { const gitResult = analyzeGit(stripped); if (gitResult) { return gitResult; } } if (isRm) { if (cwdForRm && isHomeDirectory(cwdForRm)) { if (hasRecursiveForceFlags(stripped)) { return REASON_RM_HOME_CWD; } } const rmResult = analyzeRm(stripped, { cwd: cwdForRm, originalCwd, paranoid: options.paranoidRm, allowTmpdirVar }); if (rmResult) { return rmResult; } } if (isFind) { const findResult = analyzeFind(stripped); if (findResult) { return findResult; } } if (isXargs) { const xargsResult = analyzeXargs(stripped, { cwd: cwdForRm, originalCwd, paranoidRm: options.paranoidRm, allowTmpdirVar }); if (xargsResult) { return xargsResult; } } if (isParallel) { const parallelResult = analyzeParallel(stripped, { cwd: cwdForRm, originalCwd, paranoidRm: options.paranoidRm, allowTmpdirVar, analyzeNested: options.analyzeNested }); if (parallelResult) { return parallelResult; } } const matchedKnown = isGit || isRm || isFind || isXargs || isParallel; if (!matchedKnown) { if (!DISPLAY_COMMANDS.has(normalizedHead)) { for (let i = 1;i < stripped.length; i++) { const token = stripped[i]; if (!token) continue; const cmd = normalizeCommandToken(token); if (cmd === "rm") { const rmTokens = ["rm", ...stripped.slice(i + 1)]; const reason = analyzeRm(rmTokens, { cwd: cwdForRm, originalCwd, paranoid: options.paranoidRm, allowTmpdirVar }); if (reason) { return reason; } } if (cmd === "git") { const gitTokens = ["git", ...stripped.slice(i + 1)]; const reason = analyzeGit(gitTokens); if (reason) { return reason; } } if (cmd === "find") { const findTokens = ["find", ...stripped.slice(i + 1)]; const reason = analyzeFind(findTokens); if (reason) { return reason; } } } } } const customRulesTopLevelOnly = isGit || isRm || isFind || isXargs || isParallel; if (depth === 0 || !customRulesTopLevelOnly) { const customResult = checkCustomRules(stripped, options.config.rules); if (customResult) { return customResult; } } return null; } var CWD_CHANGE_REGEX = /^\s*(?:\$\(\s*)?[({]*\s*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)(?:\s|$)/; function segmentChangesCwd(segment) { const stripped = stripLeadingGrouping(segment); const unwrapped = stripWrappers([...stripped]); if (unwrapped.length === 0) { return false; } let head = unwrapped[0] ?? ""; if (head === "builtin" && unwrapped.length > 1) { head = unwrapped[1] ?? ""; } if (head === "cd" || head === "pushd" || head === "popd") { return true; } const joined = segment.join(" "); return CWD_CHANGE_REGEX.test(joined); } function stripLeadingGrouping(tokens) { let i = 0; while (i < tokens.length) { const token = tokens[i]; if (token === "{" || token === "(" || token === "$(") { i++; } else { break; } } return tokens.slice(i); } // src/core/analyze/analyze-command.ts var REASON_STRICT_UNPARSEABLE = "Command could not be safely analyzed (strict mode). Verify manually."; var REASON_RECURSION_LIMIT = "Command exceeds maximum recursion depth and cannot be safely analyzed."; function analyzeCommandInternal(command, depth, options) { if (depth >= MAX_RECURSION_DEPTH) { return { reason: REASON_RECURSION_LIMIT, segment: command }; } const segments = splitShellCommands(command); if (options.strict && segments.length === 1 && segments[0]?.length === 1 && segments[0][0] === command && command.includes(" ")) { return { reason: REASON_STRICT_UNPARSEABLE, segment: command }; } const originalCwd = options.cwd; let effectiveCwd = options.cwd; for (const segment of segments) { const segmentStr = segment.join(" "); if (segment.length === 1 && segment[0]?.includes(" ")) { const textReason = dangerousInText(segment[0]); if (textReason) { return { reason: textReason, segment: segmentStr }; } if (segmentChangesCwd(segment)) { effectiveCwd = null; } continue; } const reason = analyzeSegment(segment, depth, { ...options, cwd: originalCwd, effectiveCwd, analyzeNested: (nestedCommand) => { return analyzeCommandInternal(nestedCommand, depth + 1, options)?.reason ?? null; } }); if (reason) { return { reason, segment: segmentStr }; } if (segmentChangesCwd(segment)) { effectiveCwd = null; } } return null; } // src/core/config.ts import { existsSync, readFileSync } from "node:fs"; import { homedir as homedir2 } from "node:os"; import { join, resolve as resolve2 } from "node:path"; var DEFAULT_CONFIG = { version: 1, rules: [] }; function loadConfig(cwd, options) { const safeCwd = typeof cwd === "string" ? cwd : process.cwd(); const userConfigDir = options?.userConfigDir ?? join(homedir2(), ".cc-safety-net"); const userConfigPath = join(userConfigDir, "config.json"); const projectConfigPath = join(safeCwd, ".safety-net.json"); const userConfig = loadSingleConfig(userConfigPath); const projectConfig = loadSingleConfig(projectConfigPath); return mergeConfigs(userConfig, projectConfig); } function loadSingleConfig(path) { if (!existsSync(path)) { return null; } try { const content = readFileSync(path, "utf-8"); if (!content.trim()) { return null; } const parsed = JSON.parse(content); const result = validateConfig(parsed); if (result.errors.length > 0) { return null; } const cfg = parsed; return { version: cfg.version, rules: cfg.rules ?? [] }; } catch { return null; } } function mergeConfigs(userConfig, projectConfig) { if (!userConfig && !projectConfig) { return DEFAULT_CONFIG; } if (!userConfig) { return projectConfig ?? DEFAULT_CONFIG; } if (!projectConfig) { return userConfig; } const projectRuleNames = new Set(projectConfig.rules.map((r) => r.name.toLowerCase())); const mergedRules = [ ...userConfig.rules.filter((r) => !projectRuleNames.has(r.name.toLowerCase())), ...projectConfig.rules ]; return { version: 1, rules: mergedRules }; } function validateConfig(config) { const errors = []; const ruleNames = new Set; if (!config || typeof config !== "object") { errors.push("Config must be an object"); return { errors, ruleNames }; } const cfg = config; if (cfg.version !== 1) { errors.push("version must be 1"); } if (cfg.rules !== undefined) { if (!Array.isArray(cfg.rules)) { errors.push("rules must be an array"); } else { for (let i = 0;i < cfg.rules.length; i++) { const rule = cfg.rules[i]; const ruleErrors = validateRule(rule, i, ruleNames); errors.push(...ruleErrors); } } } return { errors, ruleNames }; } function validateRule(rule, index, ruleNames) { const errors = []; const prefix = `rules[${index}]`; if (!rule || typeof rule !== "object") { errors.push(`${prefix}: must be an object`); return errors; } const r = rule; if (typeof r.name !== "string") { errors.push(`${prefix}.name: required string`); } else { if (!NAME_PATTERN.test(r.name)) { errors.push(`${prefix}.name: must match pattern (letters, numbers, hyphens, underscores; max 64 chars)`); } const lowerName = r.name.toLowerCase(); if (ruleNames.has(lowerName)) { errors.push(`${prefix}.name: duplicate rule name "${r.name}"`); } else { ruleNames.add(lowerName); } } if (typeof r.command !== "string") { errors.push(`${prefix}.command: required string`); } else if (!COMMAND_PATTERN.test(r.command)) { errors.push(`${prefix}.command: must match pattern (letters, numbers, hyphens, underscores)`); } if (r.subcommand !== undefined) { if (typeof r.subcommand !== "string") { errors.push(`${prefix}.subcommand: must be a string if provided`); } else if (!COMMAND_PATTERN.test(r.subcommand)) { errors.push(`${prefix}.subcommand: must match pattern (letters, numbers, hyphens, underscores)`); } } if (!Array.isArray(r.block_args)) { errors.push(`${prefix}.block_args: required array`); } else { if (r.block_args.length === 0) { errors.push(`${prefix}.block_args: must have at least one element`); } for (let i = 0;i < r.block_args.length; i++) { const arg = r.block_args[i]; if (typeof arg !== "string") { errors.push(`${prefix}.block_args[${i}]: must be a string`); } else if (arg === "") { errors.push(`${prefix}.block_args[${i}]: must not be empty`); } } } if (typeof r.reason !== "string") { errors.push(`${prefix}.reason: required string`); } else if (r.reason === "") { errors.push(`${prefix}.reason: must not be empty`); } else if (r.reason.length > MAX_REASON_LENGTH) { errors.push(`${prefix}.reason: must be at most ${MAX_REASON_LENGTH} characters`); } return errors; } function validateConfigFile(path) { const errors = []; const ruleNames = new Set; if (!existsSync(path)) { errors.push(`File not found: ${path}`); return { errors, ruleNames }; } try { const content = readFileSync(path, "utf-8"); if (!content.trim()) { errors.push("Config file is empty"); return { errors, ruleNames }; } const parsed = JSON.parse(content); return validateConfig(parsed); } catch (e) { errors.push(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`); return { errors, ruleNames }; } } function getUserConfigPath() { return join(homedir2(), ".cc-safety-net", "config.json"); } function getProjectConfigPath(cwd) { return resolve2(cwd ?? process.cwd(), ".safety-net.json"); } // src/core/analyze.ts function analyzeCommand(command, options = {}) { const config = options.config ?? loadConfig(options.cwd); return analyzeCommandInternal(command, 0, { ...options, config }); } // src/core/audit.ts import { appendFileSync, existsSync as existsSync2, mkdirSync } from "node:fs"; import { homedir as homedir3 } from "node:os"; import { join as join2 } from "node:path"; function sanitizeSessionIdForFilename(sessionId) { const raw = sessionId.trim(); if (!raw) { return null; } let safe = raw.replace(/[^A-Za-z0-9_.-]+/g, "_"); safe = safe.replace(/^[._-]+|[._-]+$/g, "").slice(0, 128); if (!safe || safe === "." || safe === "..") { return null; } return safe; } function writeAuditLog(sessionId, command, segment, reason, cwd, options = {}) { const safeSessionId = sanitizeSessionIdForFilename(sessionId); if (!safeSessionId) { return; } const home = options.homeDir ?? homedir3(); const logsDir = join2(home, ".cc-safety-net", "logs"); try { if (!existsSync2(logsDir)) { mkdirSync(logsDir, { recursive: true }); } const logFile = join2(logsDir, `${safeSessionId}.jsonl`); const entry = { ts: new Date().toISOString(), command: redactSecrets(command).slice(0, 300), segment: redactSecrets(segment).slice(0, 300), reason, cwd }; appendFileSync(logFile, `${JSON.stringify(entry)} `, "utf-8"); } catch {} } function redactSecrets(text) { let result = text; result = result.replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASS|KEY|CREDENTIALS)[A-Z0-9_]*)=([^\s]+)/gi, "$1="); result = result.replace(/(['"]?\s*authorization\s*:\s*)([^'"]+)(['"]?)/gi, "$1$3"); result = result.replace(/(authorization\s*:\s*)([^\s"']+)(\s+[^\s"']+)?/gi, "$1"); result = result.replace(/(https?:\/\/)([^\s/:@]+):([^\s@]+)@/gi, "$1:@"); result = result.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, ""); return result; } // src/core/env.ts function envTruthy(name) { const value = process.env[name]; return value === "1" || value?.toLowerCase() === "true"; } // src/core/format.ts function formatBlockedMessage(input) { const { reason, command, segment } = input; const maxLen = input.maxLen ?? 200; const redact = input.redact ?? ((t) => t); let message = `BLOCKED by Safety Net Reason: ${reason}`; if (command) { const safeCommand = redact(command); message += ` Command: ${excerpt(safeCommand, maxLen)}`; } if (segment && segment !== command) { const safeSegment = redact(segment); message += ` Segment: ${excerpt(safeSegment, maxLen)}`; } message += ` If this operation is truly needed, ask the user for explicit permission and have them run the command manually.`; return message; } function excerpt(text, maxLen) { return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text; } // src/bin/claude-code.ts function outputDeny(reason, command, segment) { const message = formatBlockedMessage({ reason, command, segment, redact: redactSecrets }); const output = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: message } }; console.log(JSON.stringify(output)); } async function runClaudeCodeHook() { const chunks = []; for await (const chunk of process.stdin) { chunks.push(chunk); } const inputText = Buffer.concat(chunks).toString("utf-8").trim(); if (!inputText) { return; } let input; try { input = JSON.parse(inputText); } catch { if (envTruthy("SAFETY_NET_STRICT")) { outputDeny("Failed to parse hook input JSON (strict mode)"); } return; } if (input.tool_name !== "Bash") { return; } const command = input.tool_input?.command; if (!command) { return; } const cwd = input.cwd ?? process.cwd(); const strict = envTruthy("SAFETY_NET_STRICT"); const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"); const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"); const config = loadConfig(cwd); const result = analyzeCommand(command, { cwd, config, strict, paranoidRm, paranoidInterpreters }); if (result) { const sessionId = input.session_id; if (sessionId) { writeAuditLog(sessionId, command, result.segment, result.reason, cwd); } outputDeny(result.reason, command, result.segment); } } // src/bin/custom-rules-doc.ts var CUSTOM_RULES_DOC = `# Custom Rules Reference Agent reference for generating \`.safety-net.json\` config files. ## Config Locations | Scope | Path | Priority | |-------|------|----------| | User | \`~/.cc-safety-net/config.json\` | Lower | | Project | \`.safety-net.json\` (cwd) | Higher (overrides user) | Duplicate rule names (case-insensitive) → project wins. ## Schema \`\`\`json { "$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json", "version": 1, "rules": [...] } \`\`\` - \`$schema\`: Optional. Enables IDE autocomplete and inline validation. - \`version\`: Required. Must be \`1\`. - \`rules\`: Optional. Defaults to \`[]\`. **Always include \`$schema\`** when generating config files for IDE support. ## Rule Fields | Field | Required | Constraints | |-------|----------|-------------| | \`name\` | Yes | \`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$\` — unique (case-insensitive) | | \`command\` | Yes | \`^[a-zA-Z][a-zA-Z0-9_-]*$\` — basename only, not path | | \`subcommand\` | No | Same pattern as command. Omit to match any. | | \`block_args\` | Yes | Non-empty array of non-empty strings | | \`reason\` | Yes | Non-empty string, max 256 chars | ## Guidelines: - \`name\`: kebab-case, descriptive (e.g., \`block-git-add-all\`) - \`command\`: binary name only, lowercase - \`subcommand\`: omit if rule applies to any subcommand - \`block_args\`: include all variants (e.g., both \`-g\` and \`--global\`) - \`reason\`: explain why blocked AND suggest alternative ## Matching Behavior - **Command**: Normalized to basename (\`/usr/bin/git\` → \`git\`) - **Subcommand**: First non-option argument after command - **Arguments**: Matched literally. Command blocked if **any** \`block_args\` item present. - **Short options**: Expanded (\`-Ap\` matches \`-A\`) - **Long options**: Exact match (\`--all-files\` does NOT match \`--all\`) - **Execution order**: Built-in rules first, then custom rules (additive only) ## Examples ### Block \`git add -A\` \`\`\`json { "$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json", "version": 1, "rules": [ { "name": "block-git-add-all", "command": "git", "subcommand": "add", "block_args": ["-A", "--all", "."], "reason": "Use 'git add ' instead." } ] } \`\`\` ### Block global npm install \`\`\`json { "$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json", "version": 1, "rules": [ { "name": "block-npm-global", "command": "npm", "subcommand": "install", "block_args": ["-g", "--global"], "reason": "Use npx or local install." } ] } \`\`\` ### Block docker system prune \`\`\`json { "$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json", "version": 1, "rules": [ { "name": "block-docker-prune", "command": "docker", "subcommand": "system", "block_args": ["prune"], "reason": "Use targeted cleanup instead." } ] } \`\`\` ## Error Handling Invalid config → silent fallback to built-in rules only. No custom rules applied. `; // src/bin/gemini-cli.ts function outputGeminiDeny(reason, command, segment) { const message = formatBlockedMessage({ reason, command, segment, redact: redactSecrets }); const output = { decision: "deny", reason: message, systemMessage: message }; console.log(JSON.stringify(output)); } async function runGeminiCLIHook() { const chunks = []; for await (const chunk of process.stdin) { chunks.push(chunk); } const inputText = Buffer.concat(chunks).toString("utf-8").trim(); if (!inputText) { return; } let input; try { input = JSON.parse(inputText); } catch { if (envTruthy("SAFETY_NET_STRICT")) { outputGeminiDeny("Failed to parse hook input JSON (strict mode)"); } return; } if (input.hook_event_name !== "BeforeTool") { return; } if (input.tool_name !== "run_shell_command") { return; } const command = input.tool_input?.command; if (!command) { return; } const cwd = input.cwd ?? process.cwd(); const strict = envTruthy("SAFETY_NET_STRICT"); const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"); const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"); const config = loadConfig(cwd); const result = analyzeCommand(command, { cwd, config, strict, paranoidRm, paranoidInterpreters }); if (result) { const sessionId = input.session_id; if (sessionId) { writeAuditLog(sessionId, command, result.segment, result.reason, cwd); } outputGeminiDeny(result.reason, command, result.segment); } } // src/bin/help.ts var version = "0.5.1"; function printHelp() { console.log(`cc-safety-net v${version} Blocks destructive git and filesystem commands before execution. USAGE: cc-safety-net -cc, --claude-code Run as Claude Code PreToolUse hook (reads JSON from stdin) cc-safety-net -gc, --gemini-cli Run as Gemini CLI BeforeTool hook (reads JSON from stdin) cc-safety-net -vc, --verify-config Validate config files cc-safety-net --custom-rules-doc Print custom rules documentation cc-safety-net --statusline Print status line with mode indicators cc-safety-net -h, --help Show this help cc-safety-net -V, --version Show version ENVIRONMENT VARIABLES: SAFETY_NET_STRICT=1 Fail-closed on unparseable commands SAFETY_NET_PARANOID=1 Enable all paranoid checks SAFETY_NET_PARANOID_RM=1 Block non-temp rm -rf within cwd SAFETY_NET_PARANOID_INTERPRETERS=1 Block interpreter one-liners CONFIG FILES: ~/.cc-safety-net/config.json User-scope config .safety-net.json Project-scope config`); } function printVersion() { console.log(version); } // src/bin/statusline.ts import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs"; import { homedir as homedir4 } from "node:os"; import { join as join3 } from "node:path"; async function readStdinAsync() { if (process.stdin.isTTY) { return null; } return new Promise((resolve3) => { let data = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk) => { data += chunk; }); process.stdin.on("end", () => { const trimmed = data.trim(); resolve3(trimmed || null); }); process.stdin.on("error", () => { resolve3(null); }); }); } function getSettingsPath() { if (process.env.CLAUDE_SETTINGS_PATH) { return process.env.CLAUDE_SETTINGS_PATH; } return join3(homedir4(), ".claude", "settings.json"); } function isPluginEnabled() { const settingsPath = getSettingsPath(); if (!existsSync3(settingsPath)) { return false; } try { const content = readFileSync2(settingsPath, "utf-8"); const settings = JSON.parse(content); if (!settings.enabledPlugins) { return false; } const pluginKey = "safety-net@cc-marketplace"; if (!(pluginKey in settings.enabledPlugins)) { return false; } return settings.enabledPlugins[pluginKey] === true; } catch { return false; } } async function printStatusline() { const enabled = isPluginEnabled(); let status; if (!enabled) { status = "\uD83D\uDEE1️ Safety Net ❌"; } else { const strict = envTruthy("SAFETY_NET_STRICT"); const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"); const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"); let modeEmojis = ""; if (strict) { modeEmojis += "\uD83D\uDD12"; } if (paranoidAll || paranoidRm && paranoidInterpreters) { modeEmojis += "\uD83D\uDC41️"; } else if (paranoidRm) { modeEmojis += "\uD83D\uDDD1️"; } else if (paranoidInterpreters) { modeEmojis += "\uD83D\uDC1A"; } const statusEmoji = modeEmojis || "✅"; status = `\uD83D\uDEE1️ Safety Net ${statusEmoji}`; } const stdinInput = await readStdinAsync(); if (stdinInput && !stdinInput.startsWith("{")) { console.log(`${stdinInput} | ${status}`); } else { console.log(status); } } // src/bin/verify-config.ts import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync } from "node:fs"; import { resolve as resolve3 } from "node:path"; var HEADER = "Safety Net Config"; var SEPARATOR = "═".repeat(HEADER.length); var SCHEMA_URL = "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json"; function printHeader() { console.log(HEADER); console.log(SEPARATOR); } function printValidConfig(scope, path, result) { console.log(` ✓ ${scope} config: ${path}`); if (result.ruleNames.size > 0) { console.log(" Rules:"); let i = 1; for (const name of result.ruleNames) { console.log(` ${i}. ${name}`); i++; } } else { console.log(" Rules: (none)"); } } function printInvalidConfig(scope, path, errors) { console.error(` ✗ ${scope} config: ${path}`); console.error(" Errors:"); let errorNum = 1; for (const error of errors) { for (const part of error.split("; ")) { console.error(` ${errorNum}. ${part}`); errorNum++; } } } function addSchemaIfMissing(path) { try { const content = readFileSync3(path, "utf-8"); const parsed = JSON.parse(content); if (parsed.$schema) { return false; } const updated = { $schema: SCHEMA_URL, ...parsed }; writeFileSync(path, JSON.stringify(updated, null, 2), "utf-8"); return true; } catch { return false; } } function verifyConfig(options = {}) { const userConfig = options.userConfigPath ?? getUserConfigPath(); const projectConfig = options.projectConfigPath ?? getProjectConfigPath(); let hasErrors = false; const configsChecked = []; printHeader(); if (existsSync4(userConfig)) { const result = validateConfigFile(userConfig); configsChecked.push({ scope: "User", path: userConfig, result }); if (result.errors.length > 0) { hasErrors = true; } } if (existsSync4(projectConfig)) { const result = validateConfigFile(projectConfig); configsChecked.push({ scope: "Project", path: resolve3(projectConfig), result }); if (result.errors.length > 0) { hasErrors = true; } } if (configsChecked.length === 0) { console.log(` No config files found. Using built-in rules only.`); return 0; } for (const { scope, path, result } of configsChecked) { if (result.errors.length > 0) { printInvalidConfig(scope, path, result.errors); } else { if (addSchemaIfMissing(path)) { console.log(` Added $schema to ${scope.toLowerCase()} config.`); } printValidConfig(scope, path, result); } } if (hasErrors) { console.error(` Config validation failed.`); return 1; } console.log(` All configs valid.`); return 0; } // src/bin/cc-safety-net.ts function printCustomRulesDoc() { console.log(CUSTOM_RULES_DOC); } function handleCliFlags() { const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { printHelp(); process.exit(0); } if (args.includes("--version") || args.includes("-V")) { printVersion(); process.exit(0); } if (args.includes("--verify-config") || args.includes("-vc")) { process.exit(verifyConfig()); } if (args.includes("--custom-rules-doc")) { printCustomRulesDoc(); process.exit(0); } if (args.includes("--statusline")) { return "statusline"; } if (args.includes("--claude-code") || args.includes("-cc")) { return "claude-code"; } if (args.includes("--gemini-cli") || args.includes("-gc")) { return "gemini-cli"; } console.error(`Unknown option: ${args[0]}`); console.error("Run 'cc-safety-net --help' for usage."); process.exit(1); } async function main() { const mode = handleCliFlags(); if (mode === "claude-code") { await runClaudeCodeHook(); } else if (mode === "gemini-cli") { await runGeminiCLIHook(); } else if (mode === "statusline") { await printStatusline(); } } main().catch((error) => { console.error("Safety Net error:", error); process.exit(1); });