Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .opencode/hooks/guardrails.sh
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
Comment on lines +20 to +40
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.
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
8 changes: 8 additions & 0 deletions .opencode/opencode.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@
"external_directory": "ask",
},
"mcp": {},
"hooks": {
"PreToolUse": [
{
"command": ".opencode/hooks/guardrails.sh",
"matcher": "bash",
},
],
},
"tools": {
"github-triage": false,
"github-pr-search": false,
Expand Down
191 changes: 191 additions & 0 deletions packages/opencode/test/hook/guardrails.test.ts
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"}'))
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.
expect(result.action).toBe("block")
expect(result.message).toContain("GUARDRAIL BLOCKED")
})

test("mkfs.ext4 is blocked", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"mkfs.ext4 /dev/sda1"}'))
expect(result.action).toBe("block")
expect(result.message).toContain("GUARDRAIL BLOCKED")
expect(result.message).toContain("Disk formatting")
})

test("dd of=/dev/sda is blocked (WARNING-1: block device writes)", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"dd if=/dev/zero of=/dev/sda"}'))
expect(result.action).toBe("block")
expect(result.message).toContain("GUARDRAIL BLOCKED")
expect(result.message).toContain("Disk formatting")
})

test("fork bomb is blocked", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":":(){ :|:& };:"}'))
expect(result.action).toBe("block")
expect(result.message).toContain("GUARDRAIL BLOCKED")
expect(result.message).toContain("Fork bomb")
})

test("fork bomb with spaces is blocked (CRITICAL-3)", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":":() { :|:& };:"}'))
expect(result.action).toBe("block")
expect(result.message).toContain("GUARDRAIL BLOCKED")
expect(result.message).toContain("Fork bomb")
})

test("DROP TABLE is blocked", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"psql -c \\"DROP TABLE users\\""}'))
expect(result.action).toBe("block")
expect(result.message).toContain("GUARDRAIL BLOCKED")
expect(result.message).toContain("Database destruction")
})

test("DROP DATABASE is blocked", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"mysql -e \\"DROP DATABASE production \\""}'))
expect(result.action).toBe("block")
expect(result.message).toContain("GUARDRAIL BLOCKED")
expect(result.message).toContain("Database destruction")
})

test("rm -rf /tmp/../../etc is blocked (path traversal)", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -rf /tmp/../../etc"}'))
expect(result.action).toBe("block")
expect(result.message).toContain("GUARDRAIL BLOCKED")
expect(result.message).toContain("Path traversal")
})
})

describe("warns on force operations (exit 0 with stderr)", () => {
test("git push --force passes with warning", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"git push --force origin main"}'))
expect(result.action).toBe("pass")
expect(result.message).toContain("GUARDRAIL WARNING")
expect(result.message).toContain("Force operation")
})

test("git reset --hard passes with warning", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"git reset --hard HEAD~1"}'))
expect(result.action).toBe("pass")
expect(result.message).toContain("GUARDRAIL WARNING")
expect(result.message).toContain("Force operation")
})
})

describe("normal commands pass clean", () => {
test("ls passes", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"ls -la"}'))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})

test("git status passes", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"git status"}'))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})

test("npm install passes", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"npm install express"}'))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})

test("rm -rf on relative path passes (safe)", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -rf ./node_modules"}'))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})

test("rm -rf /tmp/test passes (safe allowlisted path)", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -rf /tmp/test"}'))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})

test("rm -rf /tmp passes (bare safe path)", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"rm -rf /tmp"}'))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})

test("dd if= without of=/dev/ passes (safe dd usage)", async () => {
const result = await runHook(entry(), makeEnv("bash", '{"command":"dd if=/dev/zero of=./test.img bs=1M count=10"}'))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})
})
})
Loading