import * as path from "node:path"; import type { ToolCallEvent } from "@earendil-works/pi-coding-agent"; import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; import type { CompiledRules, Rules } from "./damage-prevention-rules.js"; import { evaluateGitSecretBlock, sanitizeGitCommandForLogging, } from "./git-secret-blocking.js"; import { isPathMatch, resolvePath } from "./path-utils.js"; import { type CompiledSecretPatternRule, findSecretMatches, } from "./secret-patterns.js"; export interface EvaluationResult { violationReason: string | null; shouldConfirm: boolean; } const NO_VIOLATION: EvaluationResult = { violationReason: null, shouldConfirm: false, }; const SHELL_OPERATOR_RE = /\$\(|`|&&|\|\||;|(?:^|\s)(?:\||\d*>>?|\d*<|2>&1)(?:\s|$)/; /** Known tool names that have dedicated evaluation paths. */ export const KNOWN_TOOL_NAMES = new Set([ "bash", "read", "write", "edit", "grep", "find", "ls", "run_biome", "run_vitest", "run_typecheck", "run_pytest", "run_cargo_test", "git_safe", "gh_safe", ]); export function extractPaths(event: ToolCallEvent): string[] { const paths: string[] = []; if ( isToolCallEventType("read", event) || isToolCallEventType("write", event) || isToolCallEventType("edit", event) ) { paths.push(event.input.path); } else if ( isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event) ) { paths.push(event.input.path || "."); } return paths; } /** * Evaluate zero-access path restrictions on file tool paths. * * Checks read/write/edit/grep/find/ls paths against zeroAccessPaths. * Returns a violation reason string or null. */ export function evaluatePathAccess( inputPaths: string[], rules: Rules, cwd: string, ): string | null { const followSymlinks = rules.symlinkPolicy === "follow"; for (const p of inputPaths) { const resolved = resolvePath(p, cwd, { followSymlinks }); for (const zap of rules.zeroAccessPaths) { if (isPathMatch(resolved, zap, cwd, { followSymlinks })) { return `Access to zero-access path restricted: ${zap}`; } } } return null; } function hasDisallowedShellSyntax(command: string): boolean { return SHELL_OPERATOR_RE.test(command) || /[\r\n]/.test(command); } function matchesAllowPattern( command: string, compiledRules: CompiledRules, ): boolean { return compiledRules.allowPatterns.some((rule) => { rule.pattern.lastIndex = 0; return rule.pattern.test(command); }); } /** * Evaluate a bash tool call against all bash-specific rules. * * Checks in precedence order: * 1. Blocked command patterns (hard block) * 2. Git secret scanning * 3. Zero-access paths referenced in command * 4. Read-only paths potentially modified by command * 5. No-delete paths targeted by rm/mv * 6. Whitelist-mode shell syntax bans (operators, subshells, multiline) * 7. Whitelist-mode allowlist match requirement * 8. Confirm patterns (soft block / user confirmation) */ export function evaluateBashCall( command: string, rules: Rules, compiledRules: CompiledRules, compiledSecretPatterns: CompiledSecretPatternRule[], cwd: string, ): EvaluationResult { // 1. Blocked patterns for (const rule of compiledRules.blockedPatterns) { rule.pattern.lastIndex = 0; if (rule.pattern.test(command)) { return { violationReason: rule.reason, shouldConfirm: false }; } } // 2. Git secret scanning const gitViolation = evaluateGitSecretBlock({ command, cwd, secretPatterns: compiledSecretPatterns, isPathExcluded: (filePath, repoCwd) => { const followSymlinks = rules.symlinkPolicy === "follow"; const resolved = resolvePath(filePath, repoCwd, { followSymlinks }); return rules.secretScanExcludedPaths.some((pattern) => isPathMatch(resolved, pattern, repoCwd, { followSymlinks }), ); }, }); if (gitViolation) { return { violationReason: gitViolation.reason, shouldConfirm: false }; } // 3. Zero-access paths in command for (const zap of rules.zeroAccessPaths) { if (command.includes(zap)) { return { violationReason: `Bash command references zero-access path: ${zap}`, shouldConfirm: false, }; } } // 4. Read-only paths potentially modified for (const rop of rules.readOnlyPaths) { if ( command.includes(rop) && (/[>|]/.test(command) || /\brm\b/.test(command) || /\bmv\b/.test(command) || /\bsed\b/.test(command)) ) { return { violationReason: `Bash command may modify read-only path: ${rop}`, shouldConfirm: false, }; } } // 5. No-delete paths for (const ndp of rules.noDeletePaths) { if ( command.includes(ndp) && (/\brm\b/.test(command) || /\bmv\b/.test(command)) ) { return { violationReason: `Bash command attempts to delete/move protected path: ${ndp}`, shouldConfirm: false, }; } } if (compiledRules.bashPolicy === "whitelist") { // 6. Reject compound shell features before allowlist matching if (hasDisallowedShellSyntax(command)) { return { violationReason: "compound shell operators, redirects, subshells, and multiline bash are not allowed in whitelist mode", shouldConfirm: false, }; } // 7. Require an explicit allowlist match if (!matchesAllowPattern(command, compiledRules)) { return { violationReason: "bash command is not allowlisted in whitelist mode", shouldConfirm: false, }; } } // 8. Confirm patterns for (const rule of compiledRules.confirmPatterns) { rule.pattern.lastIndex = 0; if (rule.pattern.test(command)) { return { violationReason: rule.reason, shouldConfirm: true }; } } return NO_VIOLATION; } /** * Evaluate a read tool call against path access rules. * * Reads only check zero-access paths (no modification rules apply). */ export function evaluateReadCall( inputPaths: string[], rules: Rules, cwd: string, ): EvaluationResult { const violation = evaluatePathAccess(inputPaths, rules, cwd); if (violation) { return { violationReason: violation, shouldConfirm: false }; } return NO_VIOLATION; } /** * Evaluate a write/edit tool call against file-write rules. * * Checks in precedence order: * 1. Read-only path restriction * 2. Secret scanning in package files */ export function evaluateFileWriteCall( event: ToolCallEvent, inputPaths: string[], rules: Rules, compiledSecretPatterns: CompiledSecretPatternRule[], cwd: string, ): EvaluationResult { const followSymlinks = rules.symlinkPolicy === "follow"; for (const p of inputPaths) { const resolved = resolvePath(p, cwd, { followSymlinks }); // 1. Read-only path check for (const rop of rules.readOnlyPaths) { if (isPathMatch(resolved, rop, cwd, { followSymlinks })) { return { violationReason: `Modification of read-only path restricted: ${rop}`, shouldConfirm: false, }; } } // 2. Secret scanning in package files const basename = path.basename(resolved); if (basename === "package.json" || basename === "package-lock.json") { const content = isToolCallEventType("write", event) ? String((event.input as Record).content ?? "") : String((event.input as Record).newText ?? ""); const matches = findSecretMatches(content, compiledSecretPatterns); if (matches.length > 0) { return { violationReason: `Secrets detected in ${basename} (${matches.join(", ")}). Do not store API keys or tokens in package files.`, shouldConfirm: false, }; } } } return NO_VIOLATION; } /** * Top-level evaluation dispatcher. * * Routes to the appropriate helper based on tool type. * For unknown tool types, fails open (no violation). * Callers can check `event.toolName` against `KNOWN_TOOL_NAMES` for logging. * * ## Violation Precedence (MUST NOT be changed without updating tests) * * 1. **Zero-access path check** — applies to ALL path-bearing tools * (read/write/edit/grep/find/ls) via `extractPaths()` + * `evaluatePathAccess()`. Bash events are NOT checked here because * `extractPaths` returns `[]` for bash. * 2. **Tool-specific routing**: * - **bash** → `evaluateBashCall` (see its JSDoc for internal chain) * - **write/edit** → `evaluateFileWriteCall` (read-only → secret scan) * - **read/grep/find/ls** → `NO_VIOLATION` (already checked above) * - **safe command tools** (git_safe, gh_safe, run_biome, run_vitest, * run_typecheck, run_pytest, run_cargo_test) → `NO_VIOLATION` * (these enforce their own safety via argument validation) * - **unknown** → `NO_VIOLATION` (fail open) * * See also: `test/damage-prevention-eval.test.ts` — * "violation precedence (locked)" describe block. */ export function evaluateToolCall( event: ToolCallEvent, rules: Rules, compiledRules: CompiledRules, compiledSecretPatterns: CompiledSecretPatternRule[], cwd: string, ): EvaluationResult { const inputPaths = extractPaths(event); // Zero-access path checks apply to all path-bearing tools first const pathViolation = evaluatePathAccess(inputPaths, rules, cwd); if (pathViolation) { return { violationReason: pathViolation, shouldConfirm: false }; } // Route to tool-specific evaluators if (isToolCallEventType("bash", event)) { return evaluateBashCall( event.input.command, rules, compiledRules, compiledSecretPatterns, cwd, ); } if ( isToolCallEventType("write", event) || isToolCallEventType("edit", event) ) { return evaluateFileWriteCall( event, inputPaths, rules, compiledSecretPatterns, cwd, ); } // read, grep, find, ls — only zero-access path checks (already done above) if ( isToolCallEventType("read", event) || isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event) ) { return NO_VIOLATION; } // Safe command tools (git_safe, gh_safe, run_biome, run_vitest, etc.) // bypass damage-prevention evaluation — they enforce their own safety // constraints via argument validation and direct executable invocation. if ( isToolCallEventType("git_safe", event) || isToolCallEventType("gh_safe", event) || isToolCallEventType("run_biome", event) || isToolCallEventType("run_vitest", event) || isToolCallEventType("run_typecheck", event) || isToolCallEventType("run_pytest", event) || isToolCallEventType("run_cargo_test", event) ) { return NO_VIOLATION; } // Unknown tool type: fail open (no violation). // Callers can check `event.toolName` against `KNOWN_TOOL_NAMES` for logging. return NO_VIOLATION; } export function getSafeToolInput(event: ToolCallEvent): unknown { if (isToolCallEventType("bash", event)) { return { ...event.input, command: sanitizeGitCommandForLogging(event.input.command), }; } return event.input; } export function formatSafeToolInput( event: ToolCallEvent, safeInput: unknown, ): string { if ( isToolCallEventType("bash", event) && typeof safeInput === "object" && safeInput !== null && "command" in safeInput ) { return String(safeInput.command); } return JSON.stringify(safeInput); }