feat(hook): add guardrails PreToolUse hook for bash tool#87
Conversation
Add a shell-based PreToolUse hook that blocks obviously dangerous bash commands (rm -rf /, mkfs, dd, fork bombs, DROP TABLE/DATABASE) and warns on force operations (git push --force, git reset --hard). Only targets the bash tool to minimize false positives. - .opencode/hooks/guardrails.sh: POSIX-portable script using printf - .opencode/opencode.jsonc: register hook with bash matcher - test/hook/guardrails.test.ts: 16 tests covering block/warn/pass paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CRITICAL-1: Use allowlist approach for rm -rf to block ALL root paths
(e.g. /etc, /home), not just /. Safe paths (/tmp, /var/tmp) are exempted.
- CRITICAL-2: Catch separated flags (rm -r -f /) and long flags (--recursive).
- CRITICAL-3: Make fork bomb regex space-tolerant (:() { vs :(){ ).
- HIGH-1: Document false positive on string literals as intentional.
- WARNING-1: Narrow dd pattern to only block writes to block devices (of=/dev/).
- HIGH-2: Add bypass tests for all CRITICAL/HIGH findings plus safe-path tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 2 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
|
The following comment was made by an LLM, it may be inaccurate: |
There was a problem hiding this comment.
Pull request overview
Adds a PreToolUse “guardrails” hook to enforce safety checks for the bash tool, aiming to match Claude Code’s guardrail behavior in OpenCode.
Changes:
- Add a POSIX shell hook script that blocks or warns on high-risk bash commands via exit codes and stderr messages.
- Register the hook in project
.opencode/opencode.jsoncforPreToolUsewith abashmatcher. - Add a dedicated test suite validating block/warn/pass behavior across a variety of command patterns.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
.opencode/hooks/guardrails.sh |
Implements command pattern detection to block destructive ops and warn on forceful git actions. |
.opencode/opencode.jsonc |
Configures the PreToolUse hook to run guardrails for the bash tool. |
packages/opencode/test/hook/guardrails.test.ts |
Adds regression tests for the guardrails hook behavior (block/warn/pass). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
.opencode/hooks/guardrails.sh
Outdated
| SAFE_RM='(/tmp|/var/tmp)(/|$|\s)' | ||
| # Check combined flags: -rf, -fr, and variants with extra flags | ||
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+/'; then | ||
| if ! printf '%s\n' "$INPUT" | grep -qE "rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+${SAFE_RM}"; then | ||
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | ||
| exit 2 | ||
| fi | ||
| fi | ||
| # Check separated flags: rm -r -f / or rm -f -r / | ||
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-r.*-f.*\s+/' || printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-f.*-r.*\s+/'; then | ||
| if ! printf '%s\n' "$INPUT" | grep -qE "rm\s+.*-[rf].*-[rf].*\s+${SAFE_RM}"; then | ||
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | ||
| exit 2 | ||
| fi | ||
| fi | ||
|
|
||
| # Block disk formatting and writes to block devices | ||
| if printf '%s\n' "$INPUT" | grep -qE 'mkfs\.|dd\s+.*of=/dev/'; then | ||
| printf 'GUARDRAIL BLOCKED: Disk formatting command detected\n' >&2 | ||
| exit 2 | ||
| fi | ||
|
|
||
| # Block fork bombs (CRITICAL-3: space-tolerant regex) | ||
| if printf '%s\n' "$INPUT" | grep -qE ':\(\)\s*\{|\.fork\s*bomb'; then | ||
| printf 'GUARDRAIL BLOCKED: Fork bomb pattern detected\n' >&2 | ||
| exit 2 | ||
| fi | ||
|
|
||
| # Block database destruction | ||
| if printf '%s\n' "$INPUT" | grep -qiE 'DROP\s+(DATABASE|TABLE)\s'; then | ||
| printf 'GUARDRAIL BLOCKED: Database destruction command detected\n' >&2 | ||
| exit 2 | ||
| fi | ||
|
|
||
| # Warn on force operations (don't block, just context inject via stderr) | ||
| if printf '%s\n' "$INPUT" | grep -qE 'git\s+push\s+--force|git\s+reset\s+--hard'; then |
There was a problem hiding this comment.
grep -E in POSIX/BSD environments doesn’t support \s (it’s treated as a literal 's'), so these patterns may fail to match whitespace and the guardrail can be bypassed on macOS/BusyBox. Replace \s usages with POSIX character classes like [[:space:]] (and update SAFE_RM accordingly) throughout this script.
| SAFE_RM='(/tmp|/var/tmp)(/|$|\s)' | |
| # Check combined flags: -rf, -fr, and variants with extra flags | |
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+/'; then | |
| if ! printf '%s\n' "$INPUT" | grep -qE "rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+${SAFE_RM}"; then | |
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | |
| exit 2 | |
| fi | |
| fi | |
| # Check separated flags: rm -r -f / or rm -f -r / | |
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-r.*-f.*\s+/' || printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-f.*-r.*\s+/'; then | |
| if ! printf '%s\n' "$INPUT" | grep -qE "rm\s+.*-[rf].*-[rf].*\s+${SAFE_RM}"; then | |
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | |
| exit 2 | |
| fi | |
| fi | |
| # Block disk formatting and writes to block devices | |
| if printf '%s\n' "$INPUT" | grep -qE 'mkfs\.|dd\s+.*of=/dev/'; then | |
| printf 'GUARDRAIL BLOCKED: Disk formatting command detected\n' >&2 | |
| exit 2 | |
| fi | |
| # Block fork bombs (CRITICAL-3: space-tolerant regex) | |
| if printf '%s\n' "$INPUT" | grep -qE ':\(\)\s*\{|\.fork\s*bomb'; then | |
| printf 'GUARDRAIL BLOCKED: Fork bomb pattern detected\n' >&2 | |
| exit 2 | |
| fi | |
| # Block database destruction | |
| if printf '%s\n' "$INPUT" | grep -qiE 'DROP\s+(DATABASE|TABLE)\s'; then | |
| printf 'GUARDRAIL BLOCKED: Database destruction command detected\n' >&2 | |
| exit 2 | |
| fi | |
| # Warn on force operations (don't block, just context inject via stderr) | |
| if printf '%s\n' "$INPUT" | grep -qE 'git\s+push\s+--force|git\s+reset\s+--hard'; then | |
| SAFE_RM='(/tmp|/var/tmp)(/|$|[[:space:]])' | |
| # Check combined flags: -rf, -fr, and variants with extra flags | |
| if printf '%s\n' "$INPUT" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)[[:space:]]+/'; then | |
| if ! printf '%s\n' "$INPUT" | grep -qE "rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)[[:space:]]+${SAFE_RM}"; then | |
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | |
| exit 2 | |
| fi | |
| fi | |
| # Check separated flags: rm -r -f / or rm -f -r / | |
| if printf '%s\n' "$INPUT" | grep -qE 'rm[[:space:]]+.*-r.*-f.*[[:space:]]+/' || printf '%s\n' "$INPUT" | grep -qE 'rm[[:space:]]+.*-f.*-r.*[[:space:]]+/'; then | |
| if ! printf '%s\n' "$INPUT" | grep -qE "rm[[:space:]]+.*-[rf].*-[rf].*[[:space:]]+${SAFE_RM}"; then | |
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | |
| exit 2 | |
| fi | |
| fi | |
| # Block disk formatting and writes to block devices | |
| if printf '%s\n' "$INPUT" | grep -qE 'mkfs\.|dd[[:space:]]+.*of=/dev/'; then | |
| printf 'GUARDRAIL BLOCKED: Disk formatting command detected\n' >&2 | |
| exit 2 | |
| fi | |
| # Block fork bombs (CRITICAL-3: space-tolerant regex) | |
| if printf '%s\n' "$INPUT" | grep -qE ':\(\)[[:space:]]*\{|\.fork[[:space:]]*bomb'; then | |
| printf 'GUARDRAIL BLOCKED: Fork bomb pattern detected\n' >&2 | |
| exit 2 | |
| fi | |
| # Block database destruction | |
| if printf '%s\n' "$INPUT" | grep -qiE 'DROP[[:space:]]+(DATABASE|TABLE)[[:space:]]'; then | |
| printf 'GUARDRAIL BLOCKED: Database destruction command detected\n' >&2 | |
| exit 2 | |
| fi | |
| # Warn on force operations (don't block, just context inject via stderr) | |
| if printf '%s\n' "$INPUT" | grep -qE 'git[[:space:]]+push[[:space:]]+--force|git[[:space:]]+reset[[:space:]]+--hard'; then |
| # Catches combined flags (-rf, -fr), separated flags (-r -f), and long flags (--recursive --force). | ||
| # NOTE (HIGH-1): This may produce false positives on string literals containing these | ||
| # patterns (e.g. grep patterns, documentation). This is intentional -- the guardrail | ||
| # prioritizes safety over convenience. Users can restructure commands to avoid matches. | ||
| SAFE_RM='(/tmp|/var/tmp)(/|$|\s)' | ||
| # Check combined flags: -rf, -fr, and variants with extra flags | ||
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+/'; then | ||
| if ! printf '%s\n' "$INPUT" | grep -qE "rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+${SAFE_RM}"; then | ||
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | ||
| exit 2 | ||
| fi | ||
| fi | ||
| # Check separated flags: rm -r -f / or rm -f -r / | ||
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-r.*-f.*\s+/' || printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-f.*-r.*\s+/'; then |
There was a problem hiding this comment.
The rm long-option detection only matches --recursive when the root path immediately follows it; common forms like rm --recursive --force /etc (or rm --force --recursive /etc) won’t match and won’t be blocked. Update the regex(es) to detect recursive+force long options regardless of ordering/extra flags, and add/adjust tests to cover the bypass case.
| # Catches combined flags (-rf, -fr), separated flags (-r -f), and long flags (--recursive --force). | |
| # NOTE (HIGH-1): This may produce false positives on string literals containing these | |
| # patterns (e.g. grep patterns, documentation). This is intentional -- the guardrail | |
| # prioritizes safety over convenience. Users can restructure commands to avoid matches. | |
| SAFE_RM='(/tmp|/var/tmp)(/|$|\s)' | |
| # Check combined flags: -rf, -fr, and variants with extra flags | |
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+/'; then | |
| if ! printf '%s\n' "$INPUT" | grep -qE "rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+${SAFE_RM}"; then | |
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | |
| exit 2 | |
| fi | |
| fi | |
| # Check separated flags: rm -r -f / or rm -f -r / | |
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-r.*-f.*\s+/' || printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-f.*-r.*\s+/'; then | |
| # Catches combined flags (-rf, -fr), separated flags (-r -f), and long flags | |
| # containing both --recursive and --force in any order, even with extra options. | |
| # NOTE (HIGH-1): This may produce false positives on string literals containing these | |
| # patterns (e.g. grep patterns, documentation). This is intentional -- the guardrail | |
| # prioritizes safety over convenience. Users can restructure commands to avoid matches. | |
| SAFE_RM='(/tmp|/var/tmp)(/|$|\s)' | |
| RM_SHORT_COMBINED='rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+/|rm\s+-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\s+/' | |
| RM_SHORT_SEPARATED='rm\s+.*-r.*-f.*\s+/|rm\s+.*-f.*-r.*\s+/' | |
| RM_LONG_RECURSIVE_FORCE='rm\s+((--?[a-zA-Z][a-zA-Z-]*)\s+)*(--recursive\s+((--?[a-zA-Z][a-zA-Z-]*)\s+)*--force|--force\s+((--?[a-zA-Z][a-zA-Z-]*)\s+)*--recursive)\s+/' | |
| # Check combined flags: -rf, -fr, and variants with extra flags | |
| if printf '%s\n' "$INPUT" | grep -qE "${RM_SHORT_COMBINED}|${RM_LONG_RECURSIVE_FORCE}"; then | |
| if ! printf '%s\n' "$INPUT" | grep -qE "rm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|((--?[a-zA-Z][a-zA-Z-]*)\s+)*(--recursive\s+((--?[a-zA-Z][a-zA-Z-]*)\s+)*--force|--force\s+((--?[a-zA-Z][a-zA-Z-]*)\s+)*--recursive))\s+${SAFE_RM}"; then | |
| printf 'GUARDRAIL BLOCKED: Destructive rm -rf on root path detected\n' >&2 | |
| exit 2 | |
| fi | |
| fi | |
| # Check separated flags: rm -r -f / or rm -f -r / | |
| if printf '%s\n' "$INPUT" | grep -qE "${RM_SHORT_SEPARATED}"; then |
| }) | ||
|
|
||
| test("rm --recursive --force / is blocked (CRITICAL-2: long flags)", async () => { | ||
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive /etc"}')) |
There was a problem hiding this comment.
This test name says rm --recursive --force / ... but the command under test is rm --recursive /etc (no --force, different target). Either update the command to actually include --force (and ideally cover both option orderings), or rename the test so it accurately reflects what is being asserted.
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive /etc"}')) | |
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive --force /"}')) |
HIGH-1: SAFE_RM regex now matches bare paths (e.g. /tmp, /var/tmp) by accepting trailing /, quote, space, }, or end-of-string. HIGH-2: Block rm commands containing '..' to prevent path traversal bypasses like 'rm -rf /tmp/../../etc'. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
.opencode/hooks/guardrails.sh新規作成(POSIX shell)/配下のrm -rfは/tmp,/var/tmp以外すべてブロックWhat
Claude Code parity: guardrails enforcement hookをOpenCodeに追加。
ブロック対象:
rm -rf /[any root path](allowlist方式)、mkfs、dd of=/dev/、fork bomb、DROP TABLE/DATABASE警告対象:
git push --force、git reset --hard安全パス例外:
rm -rf /tmp/*、rm -rf ./node_modules(相対パス)Type
Verify
bun test test/hook/guardrails— 23テスト全パスbun typecheck— 13パッケージ全パスOPENCODE_TOOL_NAME=bash OPENCODE_TOOL_INPUT='{"command":"rm -rf /etc"}' sh .opencode/hooks/guardrails.sh; echo $?→ exit 2Checklist
Issue
Closes #81
🤖 Generated with Claude Code