Skip to content

feat(hook): add guardrails PreToolUse hook for bash tool#87

Merged
terisuke merged 3 commits intodevfrom
feat/guardrails-plugin
Apr 5, 2026
Merged

feat(hook): add guardrails PreToolUse hook for bash tool#87
terisuke merged 3 commits intodevfrom
feat/guardrails-plugin

Conversation

@terisuke
Copy link
Copy Markdown

@terisuke terisuke commented Apr 5, 2026

Summary

  • .opencode/hooks/guardrails.sh 新規作成(POSIX shell)
  • bash ツールの危険コマンドをブロック(exit 2)、forceオペレーションを警告(exit 0 + stderr)
  • allowlist方式: / 配下のrm -rfは /tmp, /var/tmp 以外すべてブロック
  • フラグ分離(rm -r -f)、長オプション(--recursive)、fork bomb空白バリアントに対応
  • 23テスト追加、全パス

What

Claude Code parity: guardrails enforcement hookをOpenCodeに追加。

ブロック対象: rm -rf /[any root path](allowlist方式)、mkfsdd of=/dev/、fork bomb、DROP TABLE/DATABASE
警告対象: git push --forcegit reset --hard
安全パス例外: rm -rf /tmp/*rm -rf ./node_modules(相対パス)

Type

  • Feature

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 2

Checklist

  • guardrails.sh 作成 + executable
  • CRITICAL: allowlist方式でrm -rf /[root] ブロック
  • CRITICAL: フラグ分離/長オプション対応
  • CRITICAL: fork bomb空白バリアント対応
  • dd パターン精密化 (of=/dev/ のみ)
  • printf使用 (POSIX互換)
  • バイパステスト含む23テスト
  • false positive文書化

Issue

Closes #81

🤖 Generated with Claude Code

terisuke and others added 2 commits April 5, 2026 21:59
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>
Copilot AI review requested due to automatic review settings April 5, 2026 14:00
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

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.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

The following comment was made by an LLM, it may be inaccurate:

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.jsonc for PreToolUse with a bash matcher.
  • 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.

Comment on lines +24 to +59
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
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +33
# 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
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
})

test("rm --recursive --force / is blocked (CRITICAL-2: long flags)", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive /etc"}'))
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive /etc"}'))
const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive --force /"}'))

Copilot uses AI. Check for mistakes.
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>
@terisuke terisuke merged commit 7186a1a into dev Apr 5, 2026
3 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Claude Code → OpenCode 機能ギャップ解消計画

2 participants