diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index 58ea4385..d8f08e6b 100644 --- a/__tests__/hooks/builtin-policies.test.ts +++ b/__tests__/hooks/builtin-policies.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { readFile } from "node:fs/promises"; import { execSync } from "node:child_process"; -import { BUILTIN_POLICIES, registerBuiltinPolicies } from "../../src/hooks/builtin-policies"; +import { BUILTIN_POLICIES, registerBuiltinPolicies, clearGitBranchCache } from "../../src/hooks/builtin-policies"; import { getPoliciesForEvent, clearPolicies } from "../../src/hooks/policy-registry"; import type { PolicyContext } from "../../src/hooks/policy-types"; @@ -772,6 +772,7 @@ describe("hooks/builtin-policies", () => { afterEach(() => { vi.mocked(execSync).mockReset(); + clearGitBranchCache(); }); it("blocks git commit on main", async () => { @@ -1519,6 +1520,7 @@ describe("hooks/builtin-policies", () => { afterEach(() => { vi.mocked(execSync).mockReset(); + clearGitBranchCache(); }); it("blocks git commit on a custom protected branch", async () => { diff --git a/__tests__/lib/runtime-cache.test.ts b/__tests__/lib/runtime-cache.test.ts index 0500e817..3cb2caf8 100644 --- a/__tests__/lib/runtime-cache.test.ts +++ b/__tests__/lib/runtime-cache.test.ts @@ -78,7 +78,7 @@ describe("runtimeCache", () => { }); describe("LRU eviction with maxSize", () => { - it("evicts the least-recently-used entry when maxSize is reached", async () => { + it("evicts the oldest-inserted entry when maxSize is reached", async () => { let callCount = 0; const fn = vi.fn().mockImplementation(async (x: string) => { callCount++; @@ -92,26 +92,22 @@ describe("runtimeCache", () => { await cached("c"); // cache: [a, b, c] expect(fn).toHaveBeenCalledTimes(3); - // Access "a" again (should be cached, moves to end) - const resultA = await cached("a"); // cache: [b, c, a] + // Access "a" again — cache hit, no reordering (insertion-order eviction) + const resultA = await cached("a"); // cache: [a, b, c] — no reorder expect(fn).toHaveBeenCalledTimes(3); // no new call expect(resultA).toBe("result-a-1"); - // Insert "d" — should evict "b" (least recently used) - await cached("d"); // cache: [c, a, d] + // Insert "d" — evicts "a" (oldest inserted, even though recently accessed) + await cached("d"); // cache: [b, c, d] expect(fn).toHaveBeenCalledTimes(4); - // "b" should have been evicted — next call should re-compute - await cached("b"); // cache: [a, d, b] + // "a" should have been evicted — next call should re-compute + await cached("a"); // cache: [c, d, a] expect(fn).toHaveBeenCalledTimes(5); - // "c" should also have been evicted by now - await cached("c"); // cache: [d, b, c] — evicts "a" + // "b" should also have been evicted by now + await cached("b"); // cache: [d, a, b] expect(fn).toHaveBeenCalledTimes(6); - - // "a" was evicted, should re-compute - await cached("a"); - expect(fn).toHaveBeenCalledTimes(7); }); it("does not evict when cache is under maxSize", async () => { diff --git a/app/components/session-hooks-panel.tsx b/app/components/session-hooks-panel.tsx index 5c9664f5..ab0e5f13 100644 --- a/app/components/session-hooks-panel.tsx +++ b/app/components/session-hooks-panel.tsx @@ -7,23 +7,13 @@ import { ShieldAlert, Shield, ChevronDown, - Copy, - Check, } from "lucide-react"; import PaginationControls from "@/app/components/pagination-controls"; import { searchHookActivityAction } from "@/app/actions/get-hook-activity"; import type { HookActivityPayload } from "@/app/actions/get-hook-activity"; import { useAutoRefresh } from "@/contexts/AutoRefreshContext"; - -// -- Formatters -- - -function formatRelativeTime(ts: number): string { - const diff = Date.now() - ts; - if (diff < 60_000) return `${Math.max(1, Math.floor(diff / 1000))}s ago`; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; - return `${Math.floor(diff / 86_400_000)}d ago`; -} +import { formatRelativeTime } from "@/lib/format-duration"; +import { CopyButton } from "@/app/components/copy-button"; function formatAbsoluteTime(ts: number): string { return new Date(ts).toLocaleString(undefined, { @@ -100,40 +90,6 @@ function DurationDisplay({ ms }: { ms: number }) { return {formatDuration(ms)}; } -// -- Copy Button -- - -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - const ta = document.createElement("textarea"); - ta.value = text; - ta.style.position = "fixed"; - ta.style.opacity = "0"; - document.body.appendChild(ta); - ta.select(); - document.execCommand("copy"); - document.body.removeChild(ta); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } - }; - return ( - - ); -} - // -- Decision Pill Toggle -- function DecisionPills({ diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index b7bdbf3b..f04b365d 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -27,18 +27,9 @@ import { updatePolicyParamsAction } from "@/app/actions/update-policy-params"; import { useAutoRefresh } from "@/contexts/AutoRefreshContext"; import { useUrlParams } from "@/lib/use-url-params"; import { pageToParam, paramToPage } from "@/lib/url-filter-serializers"; +import { formatRelativeTime } from "@/lib/format-duration"; import { Button } from "@/components/ui/button"; -// -- Formatters -- - -function formatRelativeTime(ts: number): string { - const diff = Date.now() - ts; - if (diff < 60_000) return `${Math.max(1, Math.floor(diff / 1000))}s ago`; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; - return `${Math.floor(diff / 86_400_000)}d ago`; -} - function formatAbsoluteTime(ts: number): string { return new Date(ts).toLocaleString(undefined, { month: "short", diff --git a/lib/format-duration.ts b/lib/format-duration.ts index 4a95ac8a..8338b313 100644 --- a/lib/format-duration.ts +++ b/lib/format-duration.ts @@ -6,6 +6,14 @@ * This module is intentionally free of Node.js imports so it can be * safely used in both server and client components. */ +export function formatRelativeTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60_000) return `${Math.max(1, Math.floor(diff / 1000))}s ago`; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / 86_400_000)}d ago`; +} + export function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = ms / 1000; diff --git a/lib/log-entries.ts b/lib/log-entries.ts index e8666948..24250898 100644 --- a/lib/log-entries.ts +++ b/lib/log-entries.ts @@ -341,7 +341,7 @@ async function parseFileContent(fileContent: string, source: LogSource): Promise if (!resultInfo) continue; const returnDate = new Date(resultInfo.timestamp); - const durationMs = resultInfo.timestampMs - entry.timestampMs; + const durationMs = Math.max(0, resultInfo.timestampMs - entry.timestampMs); block.result = { timestamp: resultInfo.timestamp, timestampFormatted: formatTimestamp(returnDate), diff --git a/lib/projects.ts b/lib/projects.ts index ed8b707e..8b99d49c 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -9,6 +9,7 @@ import { readdir, stat } from "fs/promises"; import { join, resolve, sep } from "path"; import { getClaudeProjectsPath } from "./paths"; import { runtimeCache } from "./runtime-cache"; +import { batchAll } from "./concurrency"; import { logWarn, logError } from "./logger"; import { formatDate } from "./utils"; @@ -59,10 +60,10 @@ export async function getProjectFolders(): Promise { const entries = await safeReaddir(projectsPath); if (!entries) return []; - const folders = await Promise.all( + const settled = await batchAll( entries .filter((entry) => entry.isDirectory()) - .map(async (entry) => { + .map((entry) => async () => { const folderPath = join(projectsPath, entry.name); const mtime = await getMtime(folderPath, entry.name); return { @@ -72,8 +73,12 @@ export async function getProjectFolders(): Promise { lastModified: mtime, lastModifiedFormatted: formatDate(mtime), } as ProjectFolder; - }) + }), + 16, ); + const folders = settled + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") + .map((r) => r.value); folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); return folders; @@ -142,8 +147,8 @@ export async function getSessionFiles(projectPath: string): Promise entry.isFile() && entry.name.endsWith(".jsonl") && extractSessionId(entry.name) ); - const files = await Promise.all( - jsonlEntries.map(async (entry) => { + const settled = await batchAll( + jsonlEntries.map((entry) => async () => { const filePath = join(projectPath, entry.name); const mtime = await getMtime(filePath, entry.name); return { @@ -153,8 +158,12 @@ export async function getSessionFiles(projectPath: string): Promise => r.status === "fulfilled") + .map((r) => r.value); files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); return files; diff --git a/lib/resolve-subagent-path.ts b/lib/resolve-subagent-path.ts index 02c73734..9ed7aad5 100644 --- a/lib/resolve-subagent-path.ts +++ b/lib/resolve-subagent-path.ts @@ -27,7 +27,8 @@ export async function resolveSubagentPath( return candidatePath; } catch (e) { const code = (e as NodeJS.ErrnoException).code; - if (code !== "ENOENT") continue; + if (code === "ENOENT") continue; // file not found — try next candidate + break; // unexpected error (e.g., EACCES) — stop searching } } diff --git a/lib/runtime-cache.ts b/lib/runtime-cache.ts index 93626242..90e52718 100644 --- a/lib/runtime-cache.ts +++ b/lib/runtime-cache.ts @@ -30,11 +30,6 @@ export function runtimeCache( const key = JSON.stringify(args); const entry = cache.get(key); if (entry && Date.now() < entry.expiry) { - // LRU: move to end (most recently used) and refresh expiry - if (maxSize) { - cache.delete(key); - cache.set(key, { data: entry.data, expiry: Date.now() + revalidateSeconds * 1000 }); - } return entry.data; } diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts index c82947f1..6e1f03eb 100644 --- a/src/hooks/builtin-policies.ts +++ b/src/hooks/builtin-policies.ts @@ -47,6 +47,110 @@ const SHELL_OPERATORS = new Set(["&&", "||", "|", ";"]); // argument value; the standalone-operator check above already handles bare "|" tokens. const SHELL_METACHAR_RE = /[;&<>`$()\\]/; +// -- Pre-compiled regex constants (hoisted to avoid per-call allocation) -- + +// sanitizeJwt +const JWT_RE = /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/; + +// sanitizeApiKeys +const API_KEY_PATTERNS: Array<[RegExp, string]> = [ + [/sk-ant-[A-Za-z0-9\-_]{20,}/, "Anthropic API key"], + [/sk-proj-[A-Za-z0-9\-_]{20,}/, "OpenAI project API key"], + [/sk-[A-Za-z0-9]{20,}/, "OpenAI API key"], + [/ghp_[A-Za-z0-9]{36}/, "GitHub personal access token"], + [/github_pat_[A-Za-z0-9_]{82}/, "GitHub fine-grained token"], + [/AKIA[A-Z0-9]{16}/, "AWS access key ID"], + [/sk_live_[A-Za-z0-9]{24,}/, "Stripe live secret key"], + [/sk_test_[A-Za-z0-9]{24,}/, "Stripe test secret key"], + [/AIza[0-9A-Za-z\-_]{35}/, "Google API key"], +]; + +// sanitizeConnectionStrings +const CONNECTION_STRING_RE = /(?:postgresql|postgres|mysql|mongodb(?:\+srv)?|redis|amqps?|smtps?):\/\/[^@\s]+@/; + +// sanitizePrivateKeyContent +const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/; + +// sanitizeBearerTokens +const BEARER_TOKEN_RE = /Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]{20,}/i; + +// warnDestructiveSql / warnSchemaAlteration +const SQL_TOOL_RE = /\b(?:psql|mysql|sqlite3|pgcli|clickhouse-client)\b/; +const DESTRUCTIVE_SQL_RE = /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\b)/i; +const DELETE_NO_WHERE_RE = /\bDELETE\s+FROM\b/i; +const SQL_WHERE_RE = /\bWHERE\b/i; +const SCHEMA_ALTER_RE = /\bALTER\s+TABLE\b[\s\S]*\b(?:DROP\s+COLUMN|ADD\s+COLUMN|RENAME\s+(?:COLUMN|TO)|MODIFY\s+COLUMN)\b/i; + +// warnPackagePublish +const PUBLISH_CMD_RE = /(?:npm\s+publish|bun\s+publish|pnpm\s+publish|yarn\s+npm\s+publish|twine\s+upload|poetry\s+publish|cargo\s+publish|gem\s+push)\b/; + +// protectEnvVars +const ENV_PRINTENV_RE = /(?:^|\s|;|&&|\|\|)(?:env|printenv)(?:\s|$|;|&&|\|)/; +const ECHO_ENV_RE = /echo\s+.*\$[A-Za-z_]/; +const EXPORT_RE = /(?:^|\s|;|&&|\|\|)export\s+\w+/; +const PS_ENV_VAR_RE = /\$env:[A-Za-z_]/i; +const PS_CHILDITEM_ENV_RE = /(?:Get-ChildItem|dir|gci|ls)\s+Env:/i; +const DOTNET_GETENV_RE = /\[Environment\]::GetEnvironment/i; +const CMD_ECHO_ENV_RE = /echo\s+%[A-Za-z_]/i; + +// blockEnvFiles +const ENV_FILE_PATH_RE = /(?:^|[\\/])\.env(?:\.|$)/; +const ENV_CMD_RE = /\.env(?:\b|\s|$|\.)/; + +// blockSudo +const SUDO_RE = /(?:^|;|&&|\|\|)\s*sudo\s/; +const PS_ELEVATION_RE = /Start-Process\s+.*-Verb\s+RunAs/i; +const RUNAS_RE = /(?:^|;|&&|\|\|)\s*runas\s/i; + +// blockCurlPipeSh +const CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh)/; +const PS_WEB_PIPE_RE = /(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\s+.*\|\s*(?:Invoke-Expression|iex)/i; + +// blockForcePush +const FORCE_PUSH_RE = /(?:--force|-f\b)/; + +// blockSecretsWrite +const SECRET_FILE_RE = /\.(?:pem|key)$/; +const SECRET_FILE_ID_RSA_RE = /id_rsa/; +const SECRET_FILE_CREDENTIALS_RE = /credentials/; + +// blockWorkOnMain +const GIT_COMMIT_MERGE_RE = /git\s+(?:commit|merge|rebase|cherry-pick)\b/; + +// blockFailproofaiCommands +const FAILPROOFAI_CLI_RE = /(?:^|;|&&|\|\||\|)\s*failproofai(?:\s|$)/; +const FAILPROOFAI_UNINSTALL_RE = /(?:npm\s+(?:uninstall|remove|un|r)\s.*failproofai|bun\s+remove\s.*failproofai|yarn\s+global\s+remove\s+failproofai|pnpm\s+(?:remove|uninstall|un)\s.*failproofai)/; + +// warnGitAmend +const GIT_AMEND_RE = /\bgit\s+commit\b.*--amend\b/; + +// warnGitStashDrop +const GIT_STASH_DROP_RE = /\bgit\s+stash\s+(?:drop|clear)\b/; + +// warnAllFilesStaged +const GIT_ADD_ALL_RE = /\bgit\s+add\s+(?:-A\b|--all\b|\.(?:\s|$|;|&&|\|\|))/; + +// warnGlobalPackageInstall +const NPM_GLOBAL_RE = /\bnpm\s+(?:install|i)\b(?=.*(?:\s-g\b|--global\b))/; +const YARN_GLOBAL_RE = /\byarn\s+global\s+add\b/; +const PNPM_GLOBAL_RE = /\bpnpm\s+(?:add|install|i)\b(?=.*(?:\s-g\b|--global\b))/; +const BUN_GLOBAL_RE = /\bbun\s+(?:install|add)\b(?=.*(?:\s-g\b|--global\b))/; +const CARGO_INSTALL_RE = /\bcargo\s+install\b/; +const PIP_SYSTEM_RE = /\bpip(?:3)?\s+install\b(?=.*(?:--user\b|--break-system-packages\b))/; + +// warnBackgroundProcess +const NOHUP_RE = /\bnohup\s+\S/; +const SCREEN_DETACH_RE = /\bscreen\s+-[A-Za-z]*d[A-Za-z]*\b/; +const TMUX_DETACH_RE = /\btmux\s+(?:new-session|new)\b[^|&;]*-d\b/; +const DISOWN_RE = /\bdisown\b/; +const BACKGROUND_AMPERSAND_RE = /(?(); + /** * Check if a command matches an allow pattern using token-by-token comparison. * The "*" token is a wildcard. Extra command tokens beyond the pattern are allowed, @@ -70,8 +174,7 @@ function matchesAllowedPattern(cmd: string, pattern: string): boolean { function sanitizeJwt(ctx: PolicyContext): PolicyResult { // PostToolUse: scrub JWT patterns from tool output const output = JSON.stringify(ctx.payload); - const jwtPattern = /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/; - if (jwtPattern.test(output)) { + if (JWT_RE.test(output)) { return { decision: "deny", reason: "JWT token detected in tool output", @@ -84,18 +187,7 @@ function sanitizeJwt(ctx: PolicyContext): PolicyResult { function sanitizeApiKeys(ctx: PolicyContext): PolicyResult { // PostToolUse: scrub common API key patterns from tool output const output = JSON.stringify(ctx.payload); - const patterns: Array<[RegExp, string]> = [ - [/sk-ant-[A-Za-z0-9\-_]{20,}/, "Anthropic API key"], - [/sk-proj-[A-Za-z0-9\-_]{20,}/, "OpenAI project API key"], - [/sk-[A-Za-z0-9]{20,}/, "OpenAI API key"], - [/ghp_[A-Za-z0-9]{36}/, "GitHub personal access token"], - [/github_pat_[A-Za-z0-9_]{82}/, "GitHub fine-grained token"], - [/AKIA[A-Z0-9]{16}/, "AWS access key ID"], - [/sk_live_[A-Za-z0-9]{24,}/, "Stripe live secret key"], - [/sk_test_[A-Za-z0-9]{24,}/, "Stripe test secret key"], - [/AIza[0-9A-Za-z\-_]{35}/, "Google API key"], - ]; - for (const [pattern, label] of patterns) { + for (const [pattern, label] of API_KEY_PATTERNS) { if (pattern.test(output)) { return { decision: "deny", @@ -127,7 +219,7 @@ function sanitizeApiKeys(ctx: PolicyContext): PolicyResult { function sanitizeConnectionStrings(ctx: PolicyContext): PolicyResult { // PostToolUse: scrub database connection strings with embedded credentials const output = JSON.stringify(ctx.payload); - if (/(?:postgresql|postgres|mysql|mongodb(?:\+srv)?|redis|amqps?|smtps?):\/\/[^@\s]+@/.test(output)) { + if (CONNECTION_STRING_RE.test(output)) { return { decision: "deny", reason: "Database connection string with credentials detected in tool output", @@ -140,7 +232,7 @@ function sanitizeConnectionStrings(ctx: PolicyContext): PolicyResult { function sanitizePrivateKeyContent(ctx: PolicyContext): PolicyResult { // PostToolUse: scrub PEM private key blocks from tool output const output = JSON.stringify(ctx.payload); - if (/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/.test(output)) { + if (PRIVATE_KEY_RE.test(output)) { return { decision: "deny", reason: "Private key content detected in tool output", @@ -153,7 +245,7 @@ function sanitizePrivateKeyContent(ctx: PolicyContext): PolicyResult { function sanitizeBearerTokens(ctx: PolicyContext): PolicyResult { // PostToolUse: scrub Authorization: Bearer tokens from tool output const output = JSON.stringify(ctx.payload); - if (/Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+\/]{20,}/i.test(output)) { + if (BEARER_TOKEN_RE.test(output)) { return { decision: "deny", reason: "Bearer token detected in tool output", @@ -166,17 +258,17 @@ function sanitizeBearerTokens(ctx: PolicyContext): PolicyResult { function warnDestructiveSql(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); - if (!/\b(?:psql|mysql|sqlite3|pgcli|clickhouse-client)\b/.test(cmd)) return allow(); + if (!SQL_TOOL_RE.test(cmd)) return allow(); // DROP or TRUNCATE always warns - if (/\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\b)/i.test(cmd)) { + if (DESTRUCTIVE_SQL_RE.test(cmd)) { return instruct( "STOP: This command contains destructive SQL (DROP/TRUNCATE/DELETE). Confirm with the user before executing.", ); } // DELETE FROM without WHERE warns - if (/\bDELETE\s+FROM\b/i.test(cmd) && !/\bWHERE\b/i.test(cmd)) { + if (DELETE_NO_WHERE_RE.test(cmd) && !SQL_WHERE_RE.test(cmd)) { return instruct( "STOP: This command contains destructive SQL (DROP/TRUNCATE/DELETE). Confirm with the user before executing.", ); @@ -202,7 +294,7 @@ function warnLargeFileWrite(ctx: PolicyContext): PolicyResult { function warnPackagePublish(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); - if (/(?:npm\s+publish|bun\s+publish|pnpm\s+publish|yarn\s+npm\s+publish|twine\s+upload|poetry\s+publish|cargo\s+publish|gem\s+push)\b/.test(cmd)) { + if (PUBLISH_CMD_RE.test(cmd)) { return instruct( "STOP: This command publishes a package to a public registry. Confirm with the user that this is intentional.", ); @@ -214,29 +306,29 @@ function protectEnvVars(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); // Block: env, printenv, echo $VAR, export VAR= - if (/(?:^|\s|;|&&|\|\|)(?:env|printenv)(?:\s|$|;|&&|\|)/.test(cmd)) { + if (ENV_PRINTENV_RE.test(cmd)) { return deny("Command reads environment variables"); } - if (/echo\s+.*\$[A-Za-z_]/.test(cmd)) { + if (ECHO_ENV_RE.test(cmd)) { return deny("Command echoes environment variable"); } - if (/(?:^|\s|;|&&|\|\|)export\s+\w+/.test(cmd)) { + if (EXPORT_RE.test(cmd)) { return deny("Command exports environment variable"); } // PowerShell: $env:VAR - if (/\$env:[A-Za-z_]/i.test(cmd)) { + if (PS_ENV_VAR_RE.test(cmd)) { return deny("Command reads environment variable via PowerShell"); } // PowerShell: Get-ChildItem Env: / dir env: / gci env: / ls env: - if (/(?:Get-ChildItem|dir|gci|ls)\s+Env:/i.test(cmd)) { + if (PS_CHILDITEM_ENV_RE.test(cmd)) { return deny("Command reads environment variables via PowerShell"); } // PowerShell: [Environment]::GetEnvironmentVariable - if (/\[Environment\]::GetEnvironment/i.test(cmd)) { + if (DOTNET_GETENV_RE.test(cmd)) { return deny("Command reads environment variable via .NET"); } // cmd: echo %VAR% - if (/echo\s+%[A-Za-z_]/i.test(cmd)) { + if (CMD_ECHO_ENV_RE.test(cmd)) { return deny("Command echoes environment variable via cmd"); } return allow(); @@ -247,11 +339,11 @@ function blockEnvFiles(ctx: PolicyContext): PolicyResult { const filePath = getFilePath(ctx); // Check file_path for Read/Write tools (match both / and \ path separators) - if (filePath && /(?:^|[\\/])\.env(?:\.|$)/.test(filePath)) { + if (filePath && ENV_FILE_PATH_RE.test(filePath)) { return deny("Access to .env file blocked"); } // Check Bash commands referencing .env files - if (ctx.toolName === "Bash" && /\.env(?:\b|\s|$|\.)/.test(cmd)) { + if (ctx.toolName === "Bash" && ENV_CMD_RE.test(cmd)) { return deny("Command references .env file"); } return allow(); @@ -260,18 +352,18 @@ function blockEnvFiles(ctx: PolicyContext): PolicyResult { function blockSudo(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx).trimStart(); - if (/(?:^|;|&&|\|\|)\s*sudo\s/.test(cmd) || cmd.startsWith("sudo ")) { + if (SUDO_RE.test(cmd) || cmd.startsWith("sudo ")) { // Check allowPatterns — match against parsed tokens, not raw string const allowPatterns = ((ctx.params?.allowPatterns ?? []) as string[]); if (allowPatterns.some((p) => matchesAllowedPattern(cmd, p))) return allow(); return deny("sudo commands are blocked"); } // PowerShell: Start-Process -Verb RunAs (elevation) - if (/Start-Process\s+.*-Verb\s+RunAs/i.test(cmd)) { + if (PS_ELEVATION_RE.test(cmd)) { return deny("Elevated process launch is blocked"); } // Windows: runas command - if (/(?:^|;|&&|\|\|)\s*runas\s/i.test(cmd)) { + if (RUNAS_RE.test(cmd)) { return deny("runas elevation is blocked"); } return allow(); @@ -280,11 +372,11 @@ function blockSudo(ctx: PolicyContext): PolicyResult { function blockCurlPipeSh(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); - if (/(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh)/.test(cmd)) { + if (CURL_PIPE_SH_RE.test(cmd)) { return deny("Piping downloads to shell is blocked"); } // PowerShell: iwr | iex, irm | iex, Invoke-WebRequest | Invoke-Expression - if (/(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\s+.*\|\s*(?:Invoke-Expression|iex)/i.test(cmd)) { + if (PS_WEB_PIPE_RE.test(cmd)) { return deny("Piping downloads to Invoke-Expression is blocked"); } return allow(); @@ -392,7 +484,7 @@ function blockRmRf(ctx: PolicyContext): PolicyResult { function blockForcePush(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const args = extractGitPushArgs(getCommand(ctx)); - if (args.some((a) => /(?:--force|-f\b)/.test(a))) { + if (args.some((a) => FORCE_PUSH_RE.test(a))) { return deny("Force-pushing is blocked"); } return allow(); @@ -401,7 +493,7 @@ function blockForcePush(ctx: PolicyContext): PolicyResult { function blockSecretsWrite(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Write") return allow(); const filePath = getFilePath(ctx); - if (/\.(?:pem|key)$/.test(filePath) || /id_rsa/.test(filePath) || /credentials/.test(filePath)) { + if (SECRET_FILE_RE.test(filePath) || SECRET_FILE_ID_RSA_RE.test(filePath) || SECRET_FILE_CREDENTIALS_RE.test(filePath)) { return deny("Writing secret key files is blocked"); } const additionalPatterns = ((ctx.params?.additionalPatterns ?? []) as string[]); @@ -530,17 +622,21 @@ function blockReadOutsideCwd(ctx: PolicyContext): PolicyResult { function blockWorkOnMain(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); - if (!/git\s+(?:commit|merge|rebase|cherry-pick)\b/.test(cmd)) return allow(); + if (!GIT_COMMIT_MERGE_RE.test(cmd)) return allow(); const cwd = ctx.session?.cwd; if (!cwd) return allow(); try { - const branch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd, - encoding: "utf8", - timeout: 3000, - }).trim(); + let branch = gitBranchCache.get(cwd); + if (branch === undefined) { + branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd, + encoding: "utf8", + timeout: 3000, + }).trim(); + gitBranchCache.set(cwd, branch); + } const protectedBranches = ((ctx.params?.protectedBranches ?? ["main", "master"]) as string[]); if (protectedBranches.includes(branch)) { return deny( @@ -558,12 +654,12 @@ function blockFailproofaiCommands(ctx: PolicyContext): PolicyResult { const cmd = getCommand(ctx); // Block direct failproofai CLI invocations - if (/(?:^|;|&&|\|\||\|)\s*failproofai(?:\s|$)/.test(cmd)) { + if (FAILPROOFAI_CLI_RE.test(cmd)) { return deny("Running failproofai CLI commands is blocked"); } // Block package-manager uninstallation of failproofai - if (/(?:npm\s+(?:uninstall|remove|un|r)\s.*failproofai|bun\s+remove\s.*failproofai|yarn\s+global\s+remove\s+failproofai|pnpm\s+(?:remove|uninstall|un)\s.*failproofai)/.test(cmd)) { + if (FAILPROOFAI_UNINSTALL_RE.test(cmd)) { return deny("Uninstalling failproofai is blocked"); } @@ -611,7 +707,7 @@ async function warnRepeatedToolCalls(ctx: PolicyContext): Promise function warnGitAmend(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); - if (/\bgit\s+commit\b.*--amend\b/.test(cmd)) { + if (GIT_AMEND_RE.test(cmd)) { return instruct( "STOP: This command amends the last commit, which rewrites git history. If this commit has already been pushed to a shared branch, this will cause divergence for other contributors. Confirm with the user before executing.", ); @@ -622,7 +718,7 @@ function warnGitAmend(ctx: PolicyContext): PolicyResult { function warnGitStashDrop(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); - if (/\bgit\s+stash\s+(?:drop|clear)\b/.test(cmd)) { + if (GIT_STASH_DROP_RE.test(cmd)) { return instruct( "STOP: This command permanently deletes stashed changes (git stash drop/clear). Stash entries cannot be recovered after deletion. Confirm with the user before executing.", ); @@ -633,7 +729,7 @@ function warnGitStashDrop(ctx: PolicyContext): PolicyResult { function warnAllFilesStaged(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); - if (/\bgit\s+add\s+(?:-A\b|--all\b|\.(?:\s|$|;|&&|\|\|))/.test(cmd)) { + if (GIT_ADD_ALL_RE.test(cmd)) { return instruct( "STOP: This command stages all files in the working tree (git add -A / --all / .). This may inadvertently include build artifacts, generated files, or sensitive files not covered by .gitignore. Confirm with the user before executing.", ); @@ -644,8 +740,8 @@ function warnAllFilesStaged(ctx: PolicyContext): PolicyResult { function warnSchemaAlteration(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); - if (!/\b(?:psql|mysql|sqlite3|pgcli|clickhouse-client)\b/.test(cmd)) return allow(); - if (/\bALTER\s+TABLE\b[\s\S]*\b(?:DROP\s+COLUMN|ADD\s+COLUMN|RENAME\s+(?:COLUMN|TO)|MODIFY\s+COLUMN)\b/i.test(cmd)) { + if (!SQL_TOOL_RE.test(cmd)) return allow(); + if (SCHEMA_ALTER_RE.test(cmd)) { return instruct( "STOP: This command contains a schema-altering SQL statement (ALTER TABLE with column or rename operation). Schema changes on production databases are irreversible or disruptive. Confirm with the user before executing.", ); @@ -657,14 +753,14 @@ function warnGlobalPackageInstall(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); const isGlobal = - /\bnpm\s+(?:install|i)\b(?=.*(?:\s-g\b|--global\b))/.test(cmd) || - /\byarn\s+global\s+add\b/.test(cmd) || - /\bpnpm\s+(?:add|install|i)\b(?=.*(?:\s-g\b|--global\b))/.test(cmd) || - /\bbun\s+(?:install|add)\b(?=.*(?:\s-g\b|--global\b))/.test(cmd) || - /\bcargo\s+install\b/.test(cmd) || + NPM_GLOBAL_RE.test(cmd) || + YARN_GLOBAL_RE.test(cmd) || + PNPM_GLOBAL_RE.test(cmd) || + BUN_GLOBAL_RE.test(cmd) || + CARGO_INSTALL_RE.test(cmd) || // Bare 'pip install' respects the active venv when one is present; // only flag explicit system-level flags (--user, --break-system-packages). - /\bpip(?:3)?\s+install\b(?=.*(?:--user\b|--break-system-packages\b))/.test(cmd); + PIP_SYSTEM_RE.test(cmd); if (isGlobal) { return instruct( "STOP: This command installs a package globally, which modifies the system-wide environment outside the project. This can conflict with other projects or system tools. Confirm with the user before executing.", @@ -677,11 +773,11 @@ function warnBackgroundProcess(ctx: PolicyContext): PolicyResult { if (ctx.toolName !== "Bash") return allow(); const cmd = getCommand(ctx); const isBackground = - /\bnohup\s+\S/.test(cmd) || - /\bscreen\s+-[A-Za-z]*d[A-Za-z]*\b/.test(cmd) || - /\btmux\s+(?:new-session|new)\b[^|&;]*-d\b/.test(cmd) || - /\bdisown\b/.test(cmd) || - /(? | null; +} + +function getIndexCache(): Map | null | undefined { + return (globalThis as GlobalWithCache)[INDEX_CACHE_KEY]; +} + +function setIndexCache(cache: Map | null): void { + (globalThis as GlobalWithCache)[INDEX_CACHE_KEY] = cache; +} + function getRegistry(): RegisteredPolicy[] { const g = globalThis as GlobalWithRegistry; if (!g[REGISTRY_KEY]) { @@ -37,13 +50,23 @@ export function registerPolicy( } else { registry.push(entry); } + setIndexCache(null); // invalidate on any registry change } export function getPoliciesForEvent( eventType: HookEventType, toolName?: string, ): RegisteredPolicy[] { - return getRegistry() + let cache = getIndexCache(); + if (!cache) { + cache = new Map(); + setIndexCache(cache); + } + const key = `${eventType}:${toolName ?? ""}`; + const cached = cache.get(key); + if (cached) return cached; + + const result = getRegistry() .filter((p) => { // If events specified, must match if (p.match.events && p.match.events.length > 0) { @@ -56,9 +79,12 @@ export function getPoliciesForEvent( return true; }) .sort((a, b) => b.priority - a.priority); + cache.set(key, result); + return result; } export function clearPolicies(): void { const g = globalThis as GlobalWithRegistry; g[REGISTRY_KEY] = []; + setIndexCache(null); }