import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { parse as yamlParse } from "yaml"; import { DEFAULT_GIT_BLOCKING_SECRET_PATTERNS, type SecretPatternRule, } from "./secret-patterns.js"; export interface PatternRule { pattern: string; reason: string; } export type SymlinkPolicy = "skip" | "follow"; export type BashPolicy = "blacklist" | "whitelist"; export interface Rules { /** Set to `false` to disable the rm-rf-blocker extension entirely. */ enabled?: boolean; /** How bash commands are evaluated. Default: "whitelist". */ bashPolicy?: BashPolicy; /** Regex allowlist used when bashPolicy is "whitelist". */ allowPatterns: PatternRule[]; blockedPatterns: PatternRule[]; confirmPatterns: PatternRule[]; zeroAccessPaths: string[]; readOnlyPaths: string[]; noDeletePaths: string[]; secretBlockingPatterns: SecretPatternRule[]; secretScanExcludedPaths: string[]; /** How to handle symlinks during path checks. Default: "skip". */ symlinkPolicy?: SymlinkPolicy; } export interface CompiledPatternRule { pattern: RegExp; reason: string; } export interface CompiledRules { bashPolicy: BashPolicy; allowPatterns: CompiledPatternRule[]; blockedPatterns: CompiledPatternRule[]; confirmPatterns: CompiledPatternRule[]; zeroAccessPaths: string[]; readOnlyPaths: string[]; noDeletePaths: string[]; secretBlockingPatterns: SecretPatternRule[]; secretScanExcludedPaths: string[]; symlinkPolicy: SymlinkPolicy; } export interface CompileRulesResult { rules: CompiledRules; warnings: string[]; } export const DEFAULT_RULES: Rules = Object.freeze({ enabled: true, bashPolicy: "whitelist" as BashPolicy, allowPatterns: Object.freeze([ Object.freeze({ pattern: "^\\s*$", reason: "empty bash command" }), Object.freeze({ pattern: "^pwd$", reason: "print working directory" }), Object.freeze({ pattern: "^git status(?: --short| --porcelain(?:=v[12])?)?$", reason: "read-only git status", }), Object.freeze({ pattern: "^git rev-parse --show-toplevel$", reason: "read-only git repo root lookup", }), Object.freeze({ pattern: "^git branch --show-current$", reason: "read-only git branch lookup", }), Object.freeze({ pattern: "^git log(?: --oneline)?(?: -n \\d+)?$", reason: "read-only git history", }), Object.freeze({ pattern: "^git diff(?: --(?:cached|staged|stat|name-only))?(?: -- [A-Za-z0-9_./-]+)?$", reason: "read-only git diff", }), Object.freeze({ pattern: "^ls(?:\\s+(?:-[A-Za-z-]+|\\.?[A-Za-z0-9_./-]*|[A-Za-z0-9_.-][A-Za-z0-9_./-]*))*$", reason: "read-only directory listing", }), Object.freeze({ pattern: "^find(?:\\s+\\.)?(?:\\s+-maxdepth\\s+\\d+)?(?:\\s+-mindepth\\s+\\d+)?(?:\\s+-type\\s+[fdl])?(?:\\s+-name\\s+(?:\"[^\"]+\"|'[^']+'|[^\\s;|&<>`$]+))?(?:\\s+-path\\s+(?:\"[^\"]+\"|'[^']+'|[^\\s;|&<>`$]+))?(?:\\s+-print0?)?$", reason: "read-only find query", }), Object.freeze({ pattern: "^rg(?:\\s+(?:-[A-Za-z]+|--(?:hidden|files|line-number|no-heading|smart-case|ignore-case|fixed-strings)|--glob\\s+(?:\"[^\"]+\"|'[^']+'|[^\\s;|&<>`$]+)|\"[^\"]+\"|'[^']+'|[^\\s;|&<>`$]+))*$", reason: "read-only ripgrep search", }), ] as PatternRule[]), blockedPatterns: Object.freeze([ Object.freeze({ pattern: "\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*f|--recursive.*--force|--force.*--recursive)", reason: "rm -rf / recursive force delete", }), Object.freeze({ pattern: "\\brm\\s+(-[a-zA-Z]*r|--recursive)\\s+(-[a-zA-Z]*\\s+)*[\"']?\\/", reason: "rm -r on absolute path", }), Object.freeze({ pattern: "\\b(node|nodejs|python|python3|perl|php|ruby|lua|bash|sh|zsh|fish|deno|bun|osascript)\\b", reason: "interpreter execution is not allowed", }), Object.freeze({ pattern: "\\b(npm|pnpm|yarn|npx|bunx|uv|poetry|pip|pip3|make|just)\\b", reason: "script runners and package managers are not allowed", }), Object.freeze({ pattern: "\\bfind\\b.*\\s-(exec|execdir|ok|okdir|delete)\\b", reason: "find execution/deletion flags are not allowed", }), Object.freeze({ pattern: "\\brg\\b.*\\s--pre(?:=|\\s)", reason: "rg --pre is not allowed", }), Object.freeze({ pattern: "\\bgit\\s+diff\\b.*\\s--ext-diff\\b", reason: "git diff --ext-diff is not allowed", }), ] as PatternRule[]), confirmPatterns: Object.freeze([ Object.freeze({ pattern: "\\bsudo\\b", reason: "sudo command" }), Object.freeze({ pattern: "\\b(dd|mkfs|fdisk|parted)\\b", reason: "disk manipulation tool", }), Object.freeze({ pattern: ">\\s*\\/dev\\/(sda|nvme|disk)", reason: "direct disk write", }), ] as PatternRule[]), zeroAccessPaths: Object.freeze([] as string[]), readOnlyPaths: Object.freeze([] as string[]), noDeletePaths: Object.freeze([] as string[]), secretBlockingPatterns: Object.freeze( DEFAULT_GIT_BLOCKING_SECRET_PATTERNS.map((p) => Object.freeze({ ...p }), ) as SecretPatternRule[], ), secretScanExcludedPaths: Object.freeze([] as string[]), symlinkPolicy: "skip" as SymlinkPolicy, } as Rules); export interface LoadRulesResult { rules: Rules; warnings: string[]; errors: string[]; source: "default" | "global" | "project"; } export function countRules(rules: Rules): number { return ( rules.allowPatterns.length + rules.blockedPatterns.length + rules.confirmPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length + rules.secretBlockingPatterns.length + rules.secretScanExcludedPaths.length ); } export function countRulesBreakdown(rules: Rules): Record { return { allowPatterns: rules.allowPatterns.length, blockedPatterns: rules.blockedPatterns.length, confirmPatterns: rules.confirmPatterns.length, zeroAccessPaths: rules.zeroAccessPaths.length, readOnlyPaths: rules.readOnlyPaths.length, noDeletePaths: rules.noDeletePaths.length, secretBlockingPatterns: rules.secretBlockingPatterns.length, secretScanExcludedPaths: rules.secretScanExcludedPaths.length, }; } export function compileRules(rules: Rules): CompileRulesResult { const warnings: string[] = []; const compile = (ruleset: PatternRule[]): CompiledPatternRule[] => { const compiled: CompiledPatternRule[] = []; for (const rule of ruleset) { try { compiled.push({ pattern: new RegExp(rule.pattern, "i"), reason: rule.reason, }); } catch (err) { warnings.push( `Invalid regex pattern "${rule.pattern}" (${rule.reason}): ${err instanceof Error ? err.message : String(err)}`, ); } } return compiled; }; return { rules: { bashPolicy: rules.bashPolicy ?? "whitelist", allowPatterns: compile(rules.allowPatterns), blockedPatterns: compile(rules.blockedPatterns), confirmPatterns: compile(rules.confirmPatterns), zeroAccessPaths: rules.zeroAccessPaths, readOnlyPaths: rules.readOnlyPaths, noDeletePaths: rules.noDeletePaths, secretBlockingPatterns: rules.secretBlockingPatterns, secretScanExcludedPaths: rules.secretScanExcludedPaths, symlinkPolicy: rules.symlinkPolicy ?? "skip", }, warnings, }; } function loadYamlRules(filePath: string): Partial | null { if (!fs.existsSync(filePath)) return null; const content = fs.readFileSync(filePath, "utf8"); return yamlParse(content) as Partial; } function buildRulesFromLoaded(loaded: Partial): Rules { return { enabled: loaded.enabled !== undefined ? loaded.enabled : DEFAULT_RULES.enabled, bashPolicy: loaded.bashPolicy ?? DEFAULT_RULES.bashPolicy, allowPatterns: loaded.allowPatterns ?? DEFAULT_RULES.allowPatterns, blockedPatterns: loaded.blockedPatterns ?? DEFAULT_RULES.blockedPatterns, confirmPatterns: loaded.confirmPatterns ?? DEFAULT_RULES.confirmPatterns, zeroAccessPaths: loaded.zeroAccessPaths ?? [], readOnlyPaths: loaded.readOnlyPaths ?? [], noDeletePaths: loaded.noDeletePaths ?? [], secretBlockingPatterns: loaded.secretBlockingPatterns ?? DEFAULT_RULES.secretBlockingPatterns, secretScanExcludedPaths: loaded.secretScanExcludedPaths ?? [], symlinkPolicy: loaded.symlinkPolicy ?? DEFAULT_RULES.symlinkPolicy, }; } export function loadRules(cwd: string): LoadRulesResult { const globalRulesPath = path.join( os.homedir(), ".pi", "damage-prevention-rules.yaml", ); const projectRulesPath = path.join( cwd, ".pi", "damage-prevention-rules.yaml", ); const warnings: string[] = []; const errors: string[] = []; let baseRules: Rules = DEFAULT_RULES; let source: LoadRulesResult["source"] = "default"; try { const globalLoaded = loadYamlRules(globalRulesPath); if (globalLoaded) { baseRules = buildRulesFromLoaded(globalLoaded); source = "global"; } } catch (err) { errors.push( `Failed to load global damage-prevention-rules.yaml: ${err instanceof Error ? err.message : String(err)}`, ); } try { const projectLoaded = loadYamlRules(projectRulesPath); if (projectLoaded) { if ((projectLoaded.secretScanExcludedPaths?.length ?? 0) > 0) { warnings.push( "Ignoring project secretScanExcludedPaths; secret scan exceptions are global-only.", ); } if ((projectLoaded.allowPatterns?.length ?? 0) > 0) { warnings.push( "Ignoring project allowPatterns; bash allowlists are global-only.", ); } if (projectLoaded.bashPolicy !== undefined) { warnings.push( "Ignoring project bashPolicy; bash policy is global-only.", ); } const merged: Rules = { enabled: projectLoaded.enabled !== undefined ? projectLoaded.enabled : baseRules.enabled, bashPolicy: baseRules.bashPolicy, allowPatterns: [...baseRules.allowPatterns], blockedPatterns: [ ...baseRules.blockedPatterns, ...(projectLoaded.blockedPatterns ?? []), ], confirmPatterns: [ ...baseRules.confirmPatterns, ...(projectLoaded.confirmPatterns ?? []), ], zeroAccessPaths: [ ...baseRules.zeroAccessPaths, ...(projectLoaded.zeroAccessPaths ?? []), ], readOnlyPaths: [ ...baseRules.readOnlyPaths, ...(projectLoaded.readOnlyPaths ?? []), ], noDeletePaths: [ ...baseRules.noDeletePaths, ...(projectLoaded.noDeletePaths ?? []), ], secretBlockingPatterns: [ ...baseRules.secretBlockingPatterns, ...(projectLoaded.secretBlockingPatterns ?? []), ], secretScanExcludedPaths: [...baseRules.secretScanExcludedPaths], symlinkPolicy: projectLoaded.symlinkPolicy ?? baseRules.symlinkPolicy, }; return { rules: merged, warnings, errors, source: "project" }; } } catch (err) { errors.push( `Failed to load project damage-prevention-rules.yaml: ${err instanceof Error ? err.message : String(err)}`, ); } return { rules: baseRules, warnings, errors, source }; }