Merge 9be2180b04 into da71eaebd2
This commit is contained in:
commit
721984972f
141
docs/tools/pre-exec-hooks.md
Normal file
141
docs/tools/pre-exec-hooks.md
Normal file
@ -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. `<workspace>/.clawdbot/hooks/` (preferred)
|
||||||
|
2. `<workspace>/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
|
||||||
71
examples/pre-exec-hooks/README.md
Normal file
71
examples/pre-exec-hooks/README.md
Normal file
@ -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"}'
|
||||||
|
```
|
||||||
55
examples/pre-exec-hooks/safe-db.sh
Executable file
55
examples/pre-exec-hooks/safe-db.sh
Executable file
@ -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"}'
|
||||||
51
examples/pre-exec-hooks/safe-git.sh
Executable file
51
examples/pre-exec-hooks/safe-git.sh
Executable file
@ -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"}'
|
||||||
47
examples/pre-exec-hooks/safe-rm.sh
Executable file
47
examples/pre-exec-hooks/safe-rm.sh
Executable file
@ -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"}'
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
resolveExecApprovalsFromFile,
|
resolveExecApprovalsFromFile,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
|
import { checkPreExecApproval } from "../infra/pre-exec-hooks.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
||||||
import {
|
import {
|
||||||
@ -755,6 +756,22 @@ export function createExecTool(
|
|||||||
throw new Error("Provide a command to start.");
|
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 maxOutput = DEFAULT_MAX_OUTPUT;
|
||||||
const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT;
|
const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT;
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|||||||
448
src/infra/pre-exec-hooks.ts
Normal file
448
src/infra/pre-exec-hooks.ts
Normal file
@ -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:
|
||||||
|
* - <workspace>/.clawdbot/hooks/ (preferred)
|
||||||
|
* - <workspace>/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<string, string>;
|
||||||
|
[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<string>();
|
||||||
|
|
||||||
|
// 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<PreExecHookOutput> {
|
||||||
|
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<PreExecHookResult> {
|
||||||
|
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<string, string>;
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user