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);
}