diff --git a/docs/tools/pre-exec-hooks.md b/docs/tools/pre-exec-hooks.md new file mode 100644 index 000000000..ea2b92635 --- /dev/null +++ b/docs/tools/pre-exec-hooks.md @@ -0,0 +1,141 @@ +# Pre-Exec Hooks + +Pre-exec hooks let workspace-level scripts intercept and approve/deny shell commands before they run. This provides a safety net for AI agent operations — preventing pushes to protected branches, writes to production databases, or dangerous file operations. + +## How It Works + +``` +Agent runs: git push origin develop + ↓ +Clawdbot exec tool → runs pre-exec hooks + ↓ +~/.clawdbot/hooks/safe-git.sh → receives command as JSON + ↓ +Hook outputs: {"decision": "deny", "reason": "🚫 Protected branch"} + ↓ +Exec tool throws error instead of running command +``` + +## Hook Discovery + +Hooks are discovered from these directories (in order): + +1. `/.clawdbot/hooks/` (preferred) +2. `/hooks/` (fallback) + +Any executable shell script in these directories is treated as a hook. + +## Hook Protocol + +### Input (JSON on stdin) + +```json +{ + "tool_name": "exec", + "tool_input": { + "command": "git push origin main", + "workdir": "/path/to/workspace", + "env": {} + } +} +``` + +### Output (JSON on stdout) + +```json +{ + "decision": "approve", + "reason": "optional message" +} +``` + +Or to deny: + +```json +{ + "decision": "deny", + "reason": "🚫 Pushing to protected branches is blocked." +} +``` + +## Writing a Hook + +Create an executable script in `.clawdbot/hooks/`: + +```bash +#!/bin/bash +# .clawdbot/hooks/block-sudo.sh + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +if echo "$COMMAND" | grep -qE '(^|\s)sudo\s'; then + echo '{"decision": "deny", "reason": "🚫 sudo is not allowed."}' + exit 0 +fi + +echo '{"decision": "approve"}' +``` + +Make it executable: + +```bash +chmod +x .clawdbot/hooks/block-sudo.sh +``` + +## Example Hooks + +Clawdbot includes example hooks in `examples/pre-exec-hooks/`: + +### safe-git.sh + +Blocks: +- Force pushes (`--force`, `-f`) +- Pushes to protected branches (main, develop, staging, production) +- Remote modifications (`git remote add/remove/set-url`) + +### safe-db.sh + +Blocks: +- Non-SELECT operations on remote databases +- Migrations/seeds targeting staging/production environments + +### safe-rm.sh + +Blocks: +- `rm -rf /` +- `rm` on home/system directories +- `rm -rf *` (wildcard deletion) + +## Behavior + +- **Sequential execution**: Hooks run in alphabetical order +- **Short-circuit**: First "deny" stops execution +- **Fail-open**: Timeouts and errors default to "approve" +- **Timeout**: 10 seconds per hook (configurable) + +## Environment Variables + +Hooks receive these environment variables: + +| Variable | Description | +|----------|-------------| +| `CLAWDBOT_HOOK_NAME` | Name of the current hook | +| `CLAWDBOT_TOOL_NAME` | Tool being invoked (`exec` or `Bash`) | + +## Tips + +1. **Use jq** for parsing JSON input +2. **Keep hooks fast** — they run on every command +3. **Log to stderr** — only stdout is parsed +4. **Test locally** before deploying + +```bash +# Test a hook manually +echo '{"tool_name":"exec","tool_input":{"command":"git push origin main"}}' | .clawdbot/hooks/safe-git.sh +``` + +## Related + +- [Exec Tool](exec.md) — Shell command execution +- [Exec Approvals](exec-approvals.md) — Allowlist-based command approval diff --git a/examples/pre-exec-hooks/README.md b/examples/pre-exec-hooks/README.md new file mode 100644 index 000000000..754e5aee7 --- /dev/null +++ b/examples/pre-exec-hooks/README.md @@ -0,0 +1,71 @@ +# Pre-Exec Hooks Examples + +Example shell scripts that intercept Bash/exec tool calls before they run. + +## Installation + +Copy hooks to your workspace: + +```bash +mkdir -p .clawdbot/hooks +cp safe-git.sh safe-db.sh .clawdbot/hooks/ +chmod +x .clawdbot/hooks/*.sh +``` + +## Included Hooks + +### safe-git.sh + +Protects your git workflow: +- ❌ Blocks force pushes (`--force`, `-f`) +- ❌ Blocks pushes to protected branches (main, develop, staging, production) +- ❌ Blocks remote modifications +- ✅ Allows normal git operations + +### safe-db.sh + +Protects production databases: +- ❌ Blocks INSERT, UPDATE, DELETE, DROP on remote hosts +- ❌ Blocks migrations/seeds targeting staging/production +- ✅ Allows SELECT queries on remote DBs +- ✅ Allows all operations on localhost + +### safe-rm.sh + +Prevents catastrophic deletions: +- ❌ Blocks `rm -rf /` +- ❌ Blocks `rm` on system directories +- ❌ Blocks `rm -rf *` (wildcard) +- ✅ Allows normal file deletion + +## Testing + +```bash +# Test safe-git.sh +echo '{"tool_name":"exec","tool_input":{"command":"git push origin main"}}' | ./safe-git.sh +# → {"decision": "deny", "reason": "🚫 Pushing to protected branches is blocked..."} + +# Test safe-db.sh +echo '{"tool_name":"exec","tool_input":{"command":"psql -h prod.db.com -c \"DROP TABLE users\""}}' | ./safe-db.sh +# → {"decision": "deny", "reason": "🚫 Non-SELECT operations on remote databases are blocked."} +``` + +## Writing Your Own + +See [Pre-Exec Hooks Documentation](../../docs/tools/pre-exec-hooks.md) for the full protocol. + +Basic template: + +```bash +#!/bin/bash +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +# Your logic here +if should_block "$COMMAND"; then + echo '{"decision": "deny", "reason": "Your message here"}' + exit 0 +fi + +echo '{"decision": "approve"}' +``` diff --git a/examples/pre-exec-hooks/safe-db.sh b/examples/pre-exec-hooks/safe-db.sh new file mode 100755 index 000000000..6f67ae2a0 --- /dev/null +++ b/examples/pre-exec-hooks/safe-db.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Clawdbot PreToolUse Hook: STRICT read-only for non-local databases +# Based on Claude Code hooks in ~/code/yieldnest/yieldnest-api/.claude/hooks/ +# +# Only SELECT allowed on remote DBs - everything else blocked + +INPUT=$(cat) +TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty') +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +# Only process Bash/exec tools +[[ "$TOOL" != "Bash" && "$TOOL" != "exec" ]] && echo '{"decision": "approve"}' && exit 0 + +# Known non-local DB patterns (customize as needed) +REMOTE_HOSTS="gondola|maglev|railway|\.up\.railway\.app|staging|prod|amazonaws\.com|azure|supabase|neon\.tech|planetscale" + +# Check if command involves any remote database +if echo "$COMMAND" | grep -qiE "($REMOTE_HOSTS)"; then + + # Block any non-SELECT SQL operations + if echo "$COMMAND" | grep -qiE "(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE|EXECUTE|COPY|VACUUM|ANALYZE|REINDEX|REFRESH|LOCK|COMMENT)\s"; then + echo '{"decision": "deny", "reason": "🚫 Write operation blocked on remote DB. Only SELECT queries allowed on staging/prod. Use local DB for writes."}' + exit 0 + fi + + # Block psql -c with non-SELECT + if echo "$COMMAND" | grep -qiE "psql.*-c\s*['\"]?\s*(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE)"; then + echo '{"decision": "deny", "reason": "🚫 Write operation blocked on remote DB. Only SELECT allowed."}' + exit 0 + fi + + # Block piped writes + if echo "$COMMAND" | grep -qiE "(echo|cat|printf).*\|.*psql" && echo "$COMMAND" | grep -qiE "(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)"; then + echo '{"decision": "deny", "reason": "🚫 Piped write operation blocked on remote DB."}' + exit 0 + fi +fi + +# Block ORM/migration commands on non-local +if echo "$COMMAND" | grep -qiE "(prisma|typeorm|knex|sequelize|drizzle|migrate|seed|db:push|db:seed|sync)"; then + if echo "$COMMAND" | grep -qiE "($REMOTE_HOSTS|APP_ENV.*(prod|staging)|NODE_ENV.*(prod|staging))"; then + echo '{"decision": "deny", "reason": "🚫 Migrations/seeds blocked on remote DB. Only run on local."}' + exit 0 + fi +fi + +# Block connection strings pointing to remote with potential writes +if echo "$COMMAND" | grep -qiE "(DATABASE_URL|postgres://|postgresql://|mysql://|mongodb).*($REMOTE_HOSTS)"; then + if echo "$COMMAND" | grep -qiE "(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|migrate|seed|push|sync)"; then + echo '{"decision": "deny", "reason": "🚫 Non-read operation blocked on remote DB."}' + exit 0 + fi +fi + +echo '{"decision": "approve"}' diff --git a/examples/pre-exec-hooks/safe-git.sh b/examples/pre-exec-hooks/safe-git.sh new file mode 100755 index 000000000..ae8df7dcd --- /dev/null +++ b/examples/pre-exec-hooks/safe-git.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Clawdbot PreToolUse Hook: Prevent pushes to protected branches +# Based on Claude Code hooks in ~/code/yieldnest/yieldnest-api/.claude/hooks/ +# +# Allows: all git operations EXCEPT pushing to protected branches +# Protected branches: develop, production, staging, main + +INPUT=$(cat) +TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty') +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +# Only process Bash/exec tools +[[ "$TOOL" != "Bash" && "$TOOL" != "exec" ]] && echo '{"decision": "approve"}' && exit 0 + +# Block git remote modifications +if echo "$COMMAND" | grep -qE 'git\s+remote\s+(add|remove|rm|set-url|rename)'; then + echo '{"decision": "deny", "reason": "🚫 Modifying git remotes is blocked."}' + exit 0 +fi + +# Block force push operations +# Match -f or --force as standalone arguments (preceded by whitespace, not as part of branch name) +if echo "$COMMAND" | grep -qE 'git\s+push\s+(-f\s|--force\s|-f$|--force$)'; then + echo '{"decision": "deny", "reason": "🚫 Force push is blocked."}' + exit 0 +fi +if echo "$COMMAND" | grep -qE 'git\s+push\s+\S+\s+(-f\s|--force\s|-f$|--force$)'; then + echo '{"decision": "deny", "reason": "🚫 Force push is blocked."}' + exit 0 +fi +if echo "$COMMAND" | grep -qE 'git\s+push\s+\S+\s+\S+\s+(-f|--force)(\s|$)'; then + echo '{"decision": "deny", "reason": "🚫 Force push is blocked."}' + exit 0 +fi + +# Block pushes to protected branches +if echo "$COMMAND" | grep -qE '(^|\s|;|\||&&)git\s+push'; then + # Check for protected branch names in the push command + if echo "$COMMAND" | grep -qE 'git\s+push\s+[^\s]+\s+(develop|production|staging|main)(\s|$|:)'; then + echo '{"decision": "deny", "reason": "🚫 Pushing to protected branches (develop/production/staging/main) is blocked."}' + exit 0 + fi + + # Check for push to origin with protected branch + if echo "$COMMAND" | grep -qE 'git\s+push\s+origin\s+(develop|production|staging|main)'; then + echo '{"decision": "deny", "reason": "🚫 Pushing to protected branches (develop/production/staging/main) is blocked."}' + exit 0 + fi +fi + +echo '{"decision": "approve"}' diff --git a/examples/pre-exec-hooks/safe-rm.sh b/examples/pre-exec-hooks/safe-rm.sh new file mode 100755 index 000000000..28db82c3d --- /dev/null +++ b/examples/pre-exec-hooks/safe-rm.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Clawdbot PreToolUse Hook: Prevent dangerous rm operations +# +# Blocks: +# - rm -rf / +# - rm on home directory +# - rm on common system directories +# - rm without -i on important directories + +INPUT=$(cat) +TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty') +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +# Only process Bash/exec tools +[[ "$TOOL" != "Bash" && "$TOOL" != "exec" ]] && echo '{"decision": "approve"}' && exit 0 + +# Skip if not an rm command +if ! echo "$COMMAND" | grep -qE '(^|\s|;|\||&&)rm\s'; then + echo '{"decision": "approve"}' + exit 0 +fi + +# Block rm -rf / +if echo "$COMMAND" | grep -qE 'rm\s+.*-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+/?(\s|$|;|\||&&)'; then + echo '{"decision": "deny", "reason": "🚫 rm -rf / is blocked. Use trash instead for safe deletion."}' + exit 0 +fi + +# Block rm on home directory +if echo "$COMMAND" | grep -qE 'rm\s+.*(\$HOME|~|/home/[^/]+)\s*/?(\s|$|;|\||&&)'; then + echo '{"decision": "deny", "reason": "🚫 rm on home directory is blocked. Use trash instead."}' + exit 0 +fi + +# Block rm on system directories +if echo "$COMMAND" | grep -qE 'rm\s+.*(^|\s)/(usr|bin|sbin|etc|var|opt|lib|System|Applications)\s*/?'; then + echo '{"decision": "deny", "reason": "🚫 rm on system directories is blocked."}' + exit 0 +fi + +# Block rm -rf without explicit path (could be dangerous) +if echo "$COMMAND" | grep -qE 'rm\s+.*-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s*\*'; then + echo '{"decision": "deny", "reason": "🚫 rm -rf * is too dangerous. Be more specific or use trash."}' + exit 0 +fi + +echo '{"decision": "approve"}' diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index ad77d10e6..1f1e09bf6 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -19,6 +19,7 @@ import { resolveExecApprovals, resolveExecApprovalsFromFile, } from "../infra/exec-approvals.js"; +import { checkPreExecApproval } from "../infra/pre-exec-hooks.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { @@ -755,6 +756,22 @@ export function createExecTool( throw new Error("Provide a command to start."); } + // Pre-exec hooks check (workspace-level command validation) + const workspaceDir = defaults?.cwd || process.cwd(); + const preExecResult = await checkPreExecApproval({ + workspaceDir, + toolName: "exec", + command: params.command, + workdir: params.workdir, + env: params.env, + }); + if (!preExecResult.allowed) { + const hookInfo = preExecResult.hookName ? ` (hook: ${preExecResult.hookName})` : ""; + throw new Error( + `Command blocked by pre-exec hook${hookInfo}: ${preExecResult.reason || "denied"}` + ); + } + const maxOutput = DEFAULT_MAX_OUTPUT; const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT; const warnings: string[] = []; diff --git a/src/infra/pre-exec-hooks.ts b/src/infra/pre-exec-hooks.ts new file mode 100644 index 000000000..3a80bb020 --- /dev/null +++ b/src/infra/pre-exec-hooks.ts @@ -0,0 +1,448 @@ +/** + * Pre-Exec Hooks for Clawdbot + * + * Claude Code-style hook system for intercepting and approving/denying + * Bash/exec tool calls before they run. + * + * Hook Discovery: + * - /.clawdbot/hooks/ (preferred) + * - /hooks/ (fallback, for workspace compatibility) + * + * Hook Format (shell scripts): + * - Receive JSON on stdin: {"tool_name": "exec"|"Bash", "tool_input": {...}} + * - Output JSON: {"decision": "approve"|"deny", "reason": "optional message"} + * + * Reference: Claude Code hooks in ~/code/yieldnest/yieldnest-api/.claude/hooks/ + */ + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface PreExecHookInput { + tool_name: string; // "exec" | "Bash" + tool_input: { + command: string; + workdir?: string; + env?: Record; + [key: string]: unknown; + }; +} + +export interface PreExecHookOutput { + decision: "approve" | "deny"; + reason?: string; +} + +export interface PreExecHookResult { + decision: "approve" | "deny"; + reason?: string; + hookPath?: string; + hookName?: string; + durationMs: number; +} + +export interface DiscoveredHook { + path: string; + name: string; + source: "clawdbot-hooks" | "workspace-hooks"; +} + +export interface PreExecHookConfig { + enabled?: boolean; + timeoutMs?: number; + // Hook directories to search (relative to workspace) + hookDirs?: string[]; + // Skip hooks matching these patterns + skipPatterns?: string[]; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_HOOK_DIRS = [".clawdbot/hooks", "hooks"]; +const DEFAULT_TIMEOUT_MS = 10_000; +const HOOK_FILE_EXTENSIONS = [".sh", ".bash", ".zsh", ".fish", ""]; + +// ============================================================================ +// Hook Discovery +// ============================================================================ + +/** + * Check if a file is executable + */ +function isExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Check if a file looks like a shell script + */ +function isShellScript(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + + // Check extension + const ext = path.extname(filePath).toLowerCase(); + if (HOOK_FILE_EXTENSIONS.includes(ext)) { + // Check shebang for extensionless files + if (ext === "") { + const content = fs.readFileSync(filePath, "utf-8"); + const firstLine = content.split("\n")[0] || ""; + return firstLine.startsWith("#!"); + } + return true; + } + return false; + } catch { + return false; + } +} + +/** + * Discover all pre-exec hooks in a directory + */ +function discoverHooksInDir( + dir: string, + source: DiscoveredHook["source"] +): DiscoveredHook[] { + const hooks: DiscoveredHook[] = []; + + if (!fs.existsSync(dir)) return hooks; + + try { + const stat = fs.statSync(dir); + if (!stat.isDirectory()) return hooks; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isFile() && isExecutable(fullPath) && isShellScript(fullPath)) { + hooks.push({ + path: fullPath, + name: entry.name, + source, + }); + } + } + } catch (err) { + console.warn(`[pre-exec-hooks] Failed to scan directory ${dir}:`, err); + } + + return hooks; +} + +/** + * Discover all pre-exec hooks for a workspace + */ +export function discoverPreExecHooks( + workspaceDir: string, + config?: PreExecHookConfig +): DiscoveredHook[] { + const hookDirs = config?.hookDirs ?? DEFAULT_HOOK_DIRS; + const allHooks: DiscoveredHook[] = []; + const seenNames = new Set(); + + // Process directories in order (first wins for duplicates) + for (const relDir of hookDirs) { + const absDir = path.resolve(workspaceDir, relDir); + const source: DiscoveredHook["source"] = + relDir === ".clawdbot/hooks" ? "clawdbot-hooks" : "workspace-hooks"; + + const hooks = discoverHooksInDir(absDir, source); + + for (const hook of hooks) { + if (!seenNames.has(hook.name)) { + seenNames.add(hook.name); + allHooks.push(hook); + } + } + } + + // Filter by skip patterns if configured + if (config?.skipPatterns?.length) { + const patterns = config.skipPatterns.map( + (p) => new RegExp(p.replace(/\*/g, ".*"), "i") + ); + return allHooks.filter( + (hook) => !patterns.some((p) => p.test(hook.name) || p.test(hook.path)) + ); + } + + return allHooks; +} + +// ============================================================================ +// Hook Execution +// ============================================================================ + +/** + * Execute a single pre-exec hook + */ +async function executeHook( + hook: DiscoveredHook, + input: PreExecHookInput, + timeoutMs: number +): Promise { + return new Promise((resolve) => { + const startTime = Date.now(); + let settled = false; + + const settle = (output: PreExecHookOutput) => { + if (settled) return; + settled = true; + resolve(output); + }; + + // Default to approve on timeout/error (fail-open for safety) + const timer = setTimeout(() => { + console.warn( + `[pre-exec-hooks] Hook ${hook.name} timed out after ${timeoutMs}ms` + ); + settle({ decision: "approve", reason: "hook timed out" }); + }, timeoutMs); + + try { + const child = spawn(hook.path, [], { + cwd: path.dirname(hook.path), + env: { + ...process.env, + CLAWDBOT_HOOK_NAME: hook.name, + CLAWDBOT_TOOL_NAME: input.tool_name, + }, + stdio: ["pipe", "pipe", "pipe"], + timeout: timeoutMs, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + // Write input JSON to stdin + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + + child.on("error", (err) => { + clearTimeout(timer); + console.warn(`[pre-exec-hooks] Hook ${hook.name} error:`, err); + settle({ decision: "approve", reason: `hook error: ${err.message}` }); + }); + + child.on("close", (code) => { + clearTimeout(timer); + const duration = Date.now() - startTime; + + if (code !== 0) { + console.warn( + `[pre-exec-hooks] Hook ${hook.name} exited with code ${code}` + ); + if (stderr) { + console.warn(`[pre-exec-hooks] stderr: ${stderr.slice(0, 500)}`); + } + // Non-zero exit defaults to approve (fail-open) + settle({ + decision: "approve", + reason: `hook exited with code ${code}`, + }); + return; + } + + // Parse output + try { + const trimmed = stdout.trim(); + if (!trimmed) { + settle({ decision: "approve" }); + return; + } + + // Handle multiple lines of output - take the last JSON line + const lines = trimmed.split("\n").filter((l) => l.trim()); + const lastLine = lines[lines.length - 1]; + + const output = JSON.parse(lastLine) as PreExecHookOutput; + + // Validate decision field + if (output.decision !== "approve" && output.decision !== "deny") { + console.warn( + `[pre-exec-hooks] Hook ${hook.name} returned invalid decision: ${output.decision}` + ); + settle({ decision: "approve", reason: "invalid hook response" }); + return; + } + + settle(output); + } catch (parseErr) { + console.warn( + `[pre-exec-hooks] Hook ${hook.name} output parse error:`, + parseErr + ); + console.warn(`[pre-exec-hooks] stdout: ${stdout.slice(0, 500)}`); + settle({ decision: "approve", reason: "hook output parse error" }); + } + }); + } catch (err) { + clearTimeout(timer); + console.warn(`[pre-exec-hooks] Failed to spawn hook ${hook.name}:`, err); + settle({ decision: "approve", reason: "failed to spawn hook" }); + } + }); +} + +/** + * Run all pre-exec hooks for a tool call + * + * Returns the aggregated result: + * - If any hook returns "deny", the overall result is "deny" + * - If all hooks return "approve", the overall result is "approve" + */ +export async function runPreExecHooks( + workspaceDir: string, + input: PreExecHookInput, + config?: PreExecHookConfig +): Promise { + const startTime = Date.now(); + + // Check if hooks are enabled + if (config?.enabled === false) { + return { + decision: "approve", + reason: "hooks disabled", + durationMs: Date.now() - startTime, + }; + } + + // Discover hooks + const hooks = discoverPreExecHooks(workspaceDir, config); + + if (hooks.length === 0) { + return { + decision: "approve", + reason: "no hooks found", + durationMs: Date.now() - startTime, + }; + } + + const timeoutMs = config?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + // Run hooks sequentially (short-circuit on deny) + for (const hook of hooks) { + const result = await executeHook(hook, input, timeoutMs); + + if (result.decision === "deny") { + return { + decision: "deny", + reason: result.reason, + hookPath: hook.path, + hookName: hook.name, + durationMs: Date.now() - startTime, + }; + } + } + + return { + decision: "approve", + durationMs: Date.now() - startTime, + }; +} + +// ============================================================================ +// Integration Helper +// ============================================================================ + +/** + * Check if a command should be allowed to run + * + * This is the main entry point for integrating with bash-tools.exec.ts + */ +export async function checkPreExecApproval(params: { + workspaceDir: string; + toolName: string; + command: string; + workdir?: string; + env?: Record; + config?: PreExecHookConfig; +}): Promise<{ + allowed: boolean; + reason?: string; + hookName?: string; + durationMs: number; +}> { + const input: PreExecHookInput = { + tool_name: params.toolName, + tool_input: { + command: params.command, + workdir: params.workdir, + env: params.env, + }, + }; + + const result = await runPreExecHooks( + params.workspaceDir, + input, + params.config + ); + + return { + allowed: result.decision === "approve", + reason: result.reason, + hookName: result.hookName, + durationMs: result.durationMs, + }; +} + +// ============================================================================ +// CLI Testing Helper +// ============================================================================ + +/** + * Test pre-exec hooks from command line + */ +export async function testPreExecHooks( + workspaceDir: string, + command: string +): Promise { + console.log(`Testing pre-exec hooks in: ${workspaceDir}`); + console.log(`Command: ${command}\n`); + + const hooks = discoverPreExecHooks(workspaceDir); + console.log(`Discovered ${hooks.length} hooks:`); + for (const hook of hooks) { + console.log(` - ${hook.name} (${hook.source}): ${hook.path}`); + } + console.log(); + + const result = await checkPreExecApproval({ + workspaceDir, + toolName: "Bash", + command, + }); + + console.log(`Result: ${result.allowed ? "APPROVED" : "DENIED"}`); + if (result.reason) { + console.log(`Reason: ${result.reason}`); + } + if (result.hookName) { + console.log(`Hook: ${result.hookName}`); + } + console.log(`Duration: ${result.durationMs}ms`); +}