Skip to content

Destructive-action guard

destructive-guard is the hook that catches the careless-mistake class of agent error: a rm -rf node_modules typed without thinking, a git push --force that overwrites someone else’s commits, a DELETE FROM users; without a WHERE clause. It runs as a PreToolUse hook on Bash calls, exits 2 to block, and prints a single line explaining why.

Why this exists

Claude Code’s system prompt warns the agent about destructive actions, but the warning is a prompt — it can be overridden, lost to compaction, or simply ignored in --permission-mode=bypassPermissions. Other agents (Copilot, Codex, Gemini, OpenCode) don’t ship any equivalent warning.

The guard is the layer below all of that: an unconditional regex gate. The agent cannot talk the runtime out of refusing the call. That asymmetry — prompt advice vs. runtime refusal — is the whole point of having a hooks layer.

Patterns blocked

PatternWhy
rm -rfRecursive force delete
git push --force / -fRewrites remote history
git reset --hardDiscards local changes
git checkout --Discards uncommitted edits
git clean -fIrreversibly removes untracked files
DROP TABLE / DROP DATABASE / TRUNCATESchema or data loss
DELETE FROM ... ; without WHEREDeletes every row
chmod -R 777World-writable recursion
find ... -deleteBulk deletion from find results
npm uninstall -gRemoves globally installed package
curl ... | bashExecutes unverified remote script

A legitimate DELETE FROM sessions WHERE expired_at < now(); is not blocked — the regex specifically requires a missing WHERE clause for DELETE. The goal is to catch carelessness, not to make valid SQL impossible.

Temp-dir carve-out

Agents generate throwaway scratch constantly — HTML mockups, snapshots, scratch dirs — under /tmp and /var/tmp, then clean it up with rm -rf. Requiring a marker for every such cleanup is pure friction, so a clean rm -rf confined entirely to a temp root is allowed without a marker:

Terminal window
rm -rf /tmp/cc-mockups-xyz # allowed, no marker needed
rm -rf /tmp/a /tmp/b # allowed — all targets under /tmp

The carve-out is deliberately strict. Its invariant: never exempt a command that could delete anything outside /tmp. Because the guard reads a command string but rm acts on the live filesystem, static prefix-matching alone is not enough — ., globs, and symlinks all resolve at runtime. So it applies only to a single, literal rm invocation, and it resolves every target with realpath before allowing it. Anything that introduces doubt falls through and stays blocked:

Still blockedWhy
rm -rf /tmp/x /home/leo/proja target outside the temp root
rm -rf /tmp/../etcpath traversal
rm -rf /tmp / rm -rf /tmp/ / rm -rf /tmp/.resolves to the bare temp root
rm -rf /tmp/* / /tmp/? / /tmp/[a-z]a glob — not statically confinable
rm -rf /tmp/link/ (link → outside)symlink resolved out of /tmp
rm -rf /tmp/x && rm -rf /etcshell composition (;, &&, |, …)
rm -rf /tmp/$(cmd) / rm -rf $TMPDIR/xcommand / variable expansion
rm -rf /tmp/octopus-handoff-*reserved Octopus artifacts (/tmp/octopus-*)
sudo rm -rf /tmp/x / /bin/rm …not a bare rm invocation

Reserved artifacts are excluded because Octopus itself writes to /tmpcontext-handoff saves the successor’s handoff document to /tmp/octopus-handoff-<timestamp>.md, so a blanket temp wipe is not safe. The other destructive patterns (git push --force, DROP TABLE, …) have no temp carve-out.

How to bypass

When the destructive action is genuinely intended, add the marker inline:

Terminal window
rm -rf node_modules # destructive-guard-ok: regenerated from package.json

The reason must be non-empty. Why an inline marker instead of an env var or a config toggle?

  • Visibility. The marker surfaces in the conversation, in command history, and in any PR diff that includes the change. Silent overrides hide the dangerous moment.
  • Per-command. Disabling globally would protect nothing for the rest of the session; per-command keeps the guard armed for everything else.
  • Audit-friendly. A reviewer reading the transcript or log sees both the action and the rationale together.

How to disable

For repos with stronger out-of-band protections (read-only production, ephemeral sandboxes, branch protection that already forbids force-push), opt out in .octopus.yml:

hooks: true
destructiveGuard: false

Disabling the guard does not affect any other hook.

Known limits

The guard is a regex over the Bash command string. Agents that obfuscate — base64-encoding the command, piping through eval, constructing the destructive string from variables, calling out to a Python one-liner — bypass it. That’s a known limitation; defeating a determined adversary is the sandbox’s job, not the hook’s. The hook’s value is stopping the common careless case, which is by far the dominant failure mode in practice.

Extending

Each pattern is a regex in hooks/pre-tool-use/destructive-guard.sh, stored as an array. Adding a pattern is a two-line change: append to the array, add a test case in tests/test_destructive_guard.sh. Prefer narrower patterns that match the dangerous form precisely over broad patterns that catch incidental matches — false positives erode trust in the guard and lead teams to disable it entirely.

Source: hooks/pre-tool-use/destructive-guard.sh