-
Notifications
You must be signed in to change notification settings - Fork 0
feat(hook): add guardrails PreToolUse hook for bash tool #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| #!/bin/sh | ||
| # guardrails.sh -- PreToolUse guardrails enforcement | ||
| # Exit 0 = pass, Exit 2 = block dangerous operations | ||
| # | ||
| # Environment variables (set by hook executor): | ||
| # OPENCODE_TOOL_NAME - name of the tool being invoked | ||
| # OPENCODE_TOOL_INPUT - JSON string of tool arguments | ||
|
|
||
| TOOL="$OPENCODE_TOOL_NAME" | ||
| INPUT="$OPENCODE_TOOL_INPUT" | ||
|
|
||
| # Only check bash/shell tool invocations | ||
| case "$TOOL" in | ||
| bash) ;; | ||
| *) exit 0 ;; | ||
| esac | ||
|
|
||
| # --- CRITICAL-1/2: Block rm with recursive+force on root paths --- | ||
| # Allowlist approach: block ANY rm -rf / pattern, then allow safe exceptions. | ||
| # 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}]|$)' | ||
|
|
||
| # Block path traversal attempts | ||
| if printf '%s\n' "$INPUT" | grep -qE 'rm\s+.*-r.*\.\.' ; then | ||
| printf 'GUARDRAIL BLOCKED: Path traversal in rm command detected\n' >&2 | ||
| exit 2 | ||
| fi | ||
|
|
||
| # 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 | ||
| printf 'GUARDRAIL WARNING: Force operation detected -- proceed with caution\n' >&2 | ||
| exit 0 | ||
| fi | ||
|
|
||
| exit 0 | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,191 @@ | ||||||
| import { describe, expect, test } from "bun:test" | ||||||
| import path from "node:path" | ||||||
| import { runHook, type HookEnv } from "../../src/hook" | ||||||
| import type { HookEntry } from "../../src/hook" | ||||||
|
|
||||||
| const REPO_ROOT = path.resolve(__dirname, "../../../..") | ||||||
| const SCRIPT = path.join(REPO_ROOT, ".opencode/hooks/guardrails.sh") | ||||||
|
|
||||||
| function makeEnv(toolName: string, toolInput: string): HookEnv { | ||||||
| return { | ||||||
| OPENCODE_HOOK_EVENT: "PreToolUse", | ||||||
| OPENCODE_TOOL_NAME: toolName, | ||||||
| OPENCODE_TOOL_INPUT: toolInput, | ||||||
| OPENCODE_PROJECT_DIR: REPO_ROOT, | ||||||
| OPENCODE_SESSION_ID: "test-guardrails", | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| function entry(): HookEntry { | ||||||
| return { command: SCRIPT, timeout: 5000 } | ||||||
| } | ||||||
|
|
||||||
| describe("guardrails hook", () => { | ||||||
| describe("non-bash tools pass without message", () => { | ||||||
| test("read tool passes", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("read", '{"path":"/tmp/file"}')) | ||||||
| expect(result.action).toBe("pass") | ||||||
| expect(result.message).toBeUndefined() | ||||||
| }) | ||||||
|
|
||||||
| test("edit tool passes", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("edit", '{"file":"test.ts"}')) | ||||||
| expect(result.action).toBe("pass") | ||||||
| expect(result.message).toBeUndefined() | ||||||
| }) | ||||||
|
|
||||||
| test("glob tool passes", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("glob", '{"pattern":"*.ts"}')) | ||||||
| expect(result.action).toBe("pass") | ||||||
| expect(result.message).toBeUndefined() | ||||||
| }) | ||||||
| }) | ||||||
|
|
||||||
| describe("blocks destructive operations (exit 2)", () => { | ||||||
| test("rm -rf / is blocked", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -rf /"}')) | ||||||
| expect(result.action).toBe("block") | ||||||
| expect(result.message).toContain("GUARDRAIL BLOCKED") | ||||||
| expect(result.message).toContain("rm -rf") | ||||||
| }) | ||||||
|
|
||||||
| test("rm -rf /* is blocked", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -rf /*"}')) | ||||||
| expect(result.action).toBe("block") | ||||||
| expect(result.message).toContain("GUARDRAIL BLOCKED") | ||||||
| }) | ||||||
|
|
||||||
| test("rm -rf /etc is blocked (CRITICAL-1: named root dirs)", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -rf /etc"}')) | ||||||
| expect(result.action).toBe("block") | ||||||
| expect(result.message).toContain("GUARDRAIL BLOCKED") | ||||||
| }) | ||||||
|
|
||||||
| test("rm -rf /home is blocked (CRITICAL-1: named root dirs)", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -rf /home"}')) | ||||||
| expect(result.action).toBe("block") | ||||||
| expect(result.message).toContain("GUARDRAIL BLOCKED") | ||||||
| }) | ||||||
|
|
||||||
| test("rm -r -f / is blocked (CRITICAL-2: separated flags)", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -r -f /"}')) | ||||||
| expect(result.action).toBe("block") | ||||||
| expect(result.message).toContain("GUARDRAIL BLOCKED") | ||||||
| }) | ||||||
|
|
||||||
| test("rm --recursive --force / is blocked (CRITICAL-2: long flags)", async () => { | ||||||
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive /etc"}')) | ||||||
|
||||||
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive /etc"}')) | |
| const result = await runHook(entry(), makeEnv("bash", '{"command":"rm --recursive --force /"}')) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
rmlong-option detection only matches--recursivewhen the root path immediately follows it; common forms likerm --recursive --force /etc(orrm --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.