Are you the author? Sign in to claim
3-level override system for Claude Code - prevents destructive system operations. Born from a real incident.
A deterministic tool-call guard for AI coding agents. It sees every tool call before it runs and blocks the catastrophic ones — rm -rf /, reading ~/.ssh, writing /etc, force-push to main, credential exfiltration — with a 3-level, agent-scoped override for when you genuinely need elevated rights, and a self-protection layer so the AI can never disarm its own guard. Same input, same verdict, every time. No LLM in the loop: it's the net for when the model's judgment (or the permission prompt) fails or gets subverted by prompt injection.
PreToolUse hook.Born from a real incident. This started after Claude Code executed a destructive
chown -Ron/etc/— a multi-hour recovery. The built-in permission system wasn't enough. This is defense-in-depth.
Hardened by self-audit. The guard was put through a white-box audit of its own code, which found five real bypasses (credential reads via
python3 -c,${IFS}obfuscation, a fail-open on a missing rules file, …). All five are fixed, each with a regression test. What it deliberately does not try to do is documented honestly in THREAT-MODEL.md — it's a bouncer inside the wall, not the wall itself.
# Claude Code: point a PreToolUse hook at command-guard.py (full guide in INSTALL.md)
cp security-rules.example.json ~/.claude/safety-guard/security-rules.json
# then register hooks/command-guard.py as a PreToolUse hook in ~/.claude/settings.json
# opencode: drop the plugin in and reuse the same rules — see opencode/README.md
Install: A full, from-scratch setup guide lives in INSTALL.md. This README explains what the guard does and why; INSTALL.md explains how to deploy it.
Built and maintained by inoX-Network. Bug reports — especially new bypasses — are very welcome.
If you used the earlier version, here is what changed:
Bash, Read, Write, Edit, MultiEdit, and NotebookEdit. The write tools are checked against protected paths and self-protection — they were previously a blind spot.override_level 0/1/2/3 individually. It is no longer "any override active → everything allowed". Paths and sudo are unlocked per level.agent_id, or it runs at level 0.bin/ scripts can never be written by the AI — no override lifts this.!.expires_at timestamps. Overrides now end on an ISO-8601 expiry (or when the owner removes the file). The old "the instance deletes its own file" model is gone — the AI cannot delete active override files.rm -rf /, mkfs, chmod 777, fork bombs, pipe-to-shell, recursive chown/chmod/chgrp on system paths. Always blocked, even with an active override.git reset --hard, force-push (incl. --force-with-lease), commit --no-verify, commit --amend, git add -A / git add ., and writing git config are always blocked. Force-push to main/master has its own dedicated rule on top.bin/ scripts — via Bash or Write/Edit. No override lifts this.~/.ssh, ~/.gnupg, /etc/shadow, /boot, /usr/bin, etc..env read protection — The Read tool cannot reach private keys, cloud credentials, or .env files without a level-1+ override. Public keys and SSH config stay open.tool call ──► PreToolUse hook ──► command-guard.py ──► reads agent_id + tool from stdin
│
┌──────────────────┬──────────┴───────────┬──────────────────────────┐
▼ ▼ ▼ ▼
Read Write/Edit/ Bash other
MultiEdit/ tools
│ NotebookEdit │ │
▼ │ ▼ EXIT 0
env / credential ▼ ALWAYS, no override: (allow)
read protection self-protection? blocked_patterns · owner_only ·
│ env write? path? force-push · git-safety ·
│ │ self-protection
▼ ▼ │
allow / block allow / block ▼
load override for THIS context
(agent_id → no inheritance)
│
▼
level-dependent: protected paths,
sudo allowlist, notify, injection
│
▼
allow (EXIT 0) / block (EXIT 2)
Every branch ends by writing an audit line. EXIT 0 allows the tool call, EXIT 2 blocks it.
The first group runs always — an active override never weakens it:
rm -rf /, mkfs, recursive chown on system paths, …)grant-override, hook-dev-mode)main/masterreset --hard, --no-verify, --amend, add -A/., writing config)Only after those does the hook load the override for the calling context and run the level-dependent checks:
additional_sudo grants; level 2+ (or additional_sudo: "all") allows all sudo.env files require a level-1+ override/etc/shadow, /etc/gshadow) — no override possible~/.ssh/config, known_hosts, authorized_keys).env write — requires a level-1+ overrideThe guard protects zones and catastrophic patterns, not "every write or delete". Knowing this contract avoids surprise:
blocked_paths_write
path (e.g. /etc, /bin, ~/.ssh) or a hard-coded self-protection path are
blocked. Detection is a write-verb gate (rm, rmdir, unlink, shred,
mv, cp, truncate, tee, ln, >, find … -delete, rsync … --delete,
git clean, …) combined with a boundary-accurate path match.rm -rf /, rm -rf ~, chmod 777, mkfs, dd of=/dev/sd…, fork bomb,
curl … | sh, recursive chown/chmod on system trees, …).rm -rf ~/Downloads/project is not blocked — the guard
is a guardrail against catastrophic and protected-zone operations, not a
general "are you sure?" for ordinary file management.os.path.normpath, no realpath/readlink) — see the _norm_path docstring
("no filesystem/symlink access, which the hook deliberately avoids"). A symlink
in an unprotected zone whose target lives in a protected zone is matched by its
lexical path, so a write through the link (> safe/link,
truncate safe/link) can reach the protected target unblocked. This is a
deliberate trade-off: resolving symlinks touches the filesystem and opens
TOCTOU, performance, and existence questions. Tracked in
BEFUND-guard-scope-symlink-2026-07-24.md (columns 3/4 of the test matrix).A static blocklist isn't enough — sometimes you legitimately need extra sudo (system maintenance) or even recursive recovery operations. Instead of disabling the guard, the override system grants scoped, explicit, auditable permissions per task.
grants.allowed_paths, path-boundary-exact); extra sudo commands (grants.additional_sudo); single-file ops on normally protected paths./usr/, /lib/, /bin/, /sbin/); chown/chmod on /etc/ beyond explicitly named files./etc/ssh/sshd_config).--confirm FULL required when granting.--snapshot <ID>); --confirm CRITICAL and --snapshot when granting; a maximum runtime (--minutes, default 120, max 1440); double confirmation; foreground only, no background agent.At all levels, the always-blocked group (blocked patterns, git-safety, self-protection, owner-only) stays in force — level 3 included.
The hook reads agent_id from the PreToolUse stdin and uses it to pick the matching override file:
agent_id field. These require a mandatory expires_at (an override without expiry is discarded).agent-<agent_id>.json whose agent_id matches exactly.No inheritance: a subagent never sees the coordinator's override, and the main session never sees an agent-bound one. Without a matching, confirmed, unexpired override, a context runs at level 0. When several valid overrides match, the highest override_level wins.
The AI cannot grant itself an override. The active override directory is self-protected, so the AI cannot write there. The flow is:
confirmed: false) into the pending directory ~/.claude/.sudo-overrides-pending/ — which is deliberately not self-protected.! ~/.claude/bin/grant-override <id> --minutes N [--confirm LABEL] [--snapshot ID]
grant-override is an owner-only command, only the owner's ! reaches it. The script sets confirmed: true, label, expires_at, granted_at, granted_by, then moves the file into the active directory ~/.claude/.sudo-overrides/.expires_at.<id> is tolerant: a file name or agent id, with/without .json, with/without the agent- prefix. --minutes must be between 1 and 1440 (default 120).
Sometimes the owner wants the AI to edit the hook sources themselves (the hook, the rules file). Dev mode is the supervised, time-boxed way to do that:
! ~/.claude/bin/hook-dev-mode on <minutes> # default 30, max 240
! ~/.claude/bin/hook-dev-mode off
! ~/.claude/bin/hook-dev-mode status
Dev mode releases only the hook source files (~/.claude/hooks and the live security-rules.json). The active override directory, settings.json, bin/, CLAUDE.md and rules/ stay hard-protected even in dev mode — otherwise the AI could write itself an override while editing the hook. hook-dev-mode is owner-only, so only the owner's ! reaches it. Fail-closed: if the flag file's expires_at is missing/expired or the flag is unparsable, dev mode is treated as OFF.
These paths can never be written by AI tool calls — neither via Bash nor via Write/Edit. No override lifts this (only the owner via !, or, for the hook sources only, an active dev mode):
| Protected path | What it is |
|---|---|
~/.claude/.sudo-overrides | active override directory |
~/.claude/bin | approval & dev-mode scripts |
~/.claude/hooks | the hook sources (dev-mode-unlockable) |
~/.claude/safety-guard/security-rules.json | the live rules (dev-mode-unlockable) |
~/.claude/.hook-dev-mode | the dev-mode flag file |
~/.claude/settings.json, ~/.claude/settings.local.json | Claude Code settings |
~/.claude/CLAUDE.md | AI context file |
~/.claude/rules | the rules document(s) |
The list is hardcoded in the hook (not in the JSON rules) on purpose: if it lived in the rules file, the protection list could be edited through itself. The pending directory ~/.claude/.sudo-overrides-pending is deliberately not protected — the AI must be able to drop proposals there.
Every decision (allow or block, by Bash/Read/Write/Edit/… or by the owner scripts) is appended as one JSON line to:
~/.claude/.agent-audit/actions.jsonl
Fields: ts, session_id, actor (the agent_id, or main for the main session), agent_type, tool, target, decision, reason, level. Before the target (the command or file path) is logged it runs through secret redaction — passwords, tokens, key=value secrets, --password/--token flags and Authorization: headers become [REDACTED], and the field is truncated to 600 characters. Logging failures never block the guard.
.env read protectionThe hook intercepts Read (and .env writes) and applies tiers:
| Tier | Behavior | Examples | Override |
|---|---|---|---|
| Always allowed | no restriction | ~/.ssh/*.pub, ~/.ssh/config, ~/.ssh/known_hosts, ~/.ssh/authorized_keys | not needed |
| Override required | blocked without level 1+ | ~/.ssh/id_*, ~/.aws/credentials, ~/.aws/config, ~/.npmrc, ~/.docker/config.json, ~/.gnupg/ | level 1+ |
.env files | read and write require level 1+ | .env, .env.local, .env.development, .env.production (matched by basename, any directory) | level 1+ |
| Always blocked | cannot be read | /etc/shadow, /etc/gshadow | none |
Without this, a prompt-injection or confused-deputy attack could trick the AI into reading your private key or .env and exfiltrating it via a later command.
No override unlocks any of these:
| Category | Examples / patterns |
|---|---|
Catastrophic rm | rm -rf /, rm -rf ~, rm -rf /*, rm -rf ., rm -rf $HOME |
| Permission destruction | chmod 777, chmod -R 777 |
| Drive overwrite | mkfs, dd if=… of=/dev/{sd,nvme,hd}, > /dev/sd |
| Recursive on system paths | chown -R / chmod -R / chgrp -R on /etc, /usr, /var, /lib, /bin, /sbin, /boot — the exact pattern from the incident |
| Remote-code execution | curl … | sh, wget … | bash, eval … base64, python -c … import os … system |
| Fork bomb | :(){ :|:& };: |
| Git safety | git reset --hard, force-push (-f / --force / --force-with-lease), commit --no-verify, commit --amend, git add -A / git add ., writing git config |
| Force-push to primary | git push --force / -f / --force-with-lease to main / master (dedicated rule) |
| Self-protection paths | the hook, rules file, rules doc, active override dir, bin/ (see above) |
| Owner-only commands | grant-override, hook-dev-mode (AI Bash calls) |
| Credential reads | /etc/shadow, /etc/gshadow |
The hook reads its rules from ~/.claude/safety-guard/security-rules.json (see security-rules.example.json for a complete starting point). Top-level keys:
| Key | Type | Description |
|---|---|---|
blocked_patterns | string[] | Always-blocked patterns. Each entry is treated as a regex if it contains regex metacharacters, otherwise as a literal substring. A literal pipe must be written |. |
owner_only_commands | string[] | Commands only the owner may run via !; hard-blocked for AI Bash. |
blocked_git_ops | string[] | Always-blocked git operations (regex). |
blocked_paths_write | string[] | Paths protected from writes (supports ~); level-dependent. |
allowed_sudo | string[] | Base allowlist of commands permitted after sudo. |
require_confirmation | string[] | Substrings that trigger a desktop notification. |
protected_reads.always_allowed | string[] | Read tool may always access (supports * globs). |
protected_reads.require_override_1 | string[] | Read needs a level-1+ override. |
protected_reads.always_blocked_reads | string[] | Never readable, no override. |
protected_reads.env_files_require_override_1 | string[] | .env filenames whose read and write need level 1+. |
blocked_bash_patterns_force_push | string[] | Regexes blocking force-push on main/master. |
prompt_injection_keywords | string[] | Keywords that emit a stderr warning (no block). |
The self-protection path list is not in this file — it is hardcoded in the hook so it cannot be edited through itself.
A proposal you write into the pending directory:
| Field | Type | Set by | Description |
|---|---|---|---|
override_level | 1 | 2 | 3 | you | Permission level (must be an int, not bool). |
task | string | you | Non-empty description of what it's for. |
project | string | null | you | Optional associated project. |
confirmed | boolean | you false → script true | The hook only honors true. |
agent_id | string | you | Subagent only — must equal the subagent's agent_id. Omit entirely for the main session. |
expires_at | ISO-8601 | the script (--minutes) | Mandatory for main-session overrides; optional but honored for agent overrides. |
label | string | the script | EXTENDED / FULL / CRITICAL. |
granted_at, granted_by | string | the script | Audit metadata. |
snapshot_id | string | the script (--snapshot) | Level 3 only. |
grants.additional_sudo | string[] | "all" | you | Extra sudo commands (base allowlist still applies). |
grants.allowed_paths | string[] | you | Level-1 write paths, path-boundary-exact. |
grants.recursive_operations | boolean | you | Informational. |
grants.system_paths | boolean | you | Informational — not evaluated by the hook; only the level controls system-path access. |
Q: Does this replace Claude Code's built-in permission system? A: No. It runs alongside it as an extra layer that catches dangerous patterns before they reach the permission prompt.
Q: Can the AI grant itself an override?
A: No. It can only write a proposal into the pending directory. Activation requires the owner to run the owner-only grant-override script via !, which bypasses the guard. The active override directory is self-protected.
Q: What happens if the rules file is missing or broken? A: The hook is fail-closed. A missing rules file means no Bash rules can be evaluated; an unparsable override or input is discarded rather than trusted. (This is a breaking change from the earlier fail-open behavior.)
Q: Do subagents inherit my session's override?
A: No. Overrides are scoped by agent_id. A subagent needs its own agent-<agent_id>.json override, or it runs at level 0.
Q: How does an override end?
A: When its expires_at passes, or when the owner removes the file via ! rm. The AI cannot delete active override files.
Q: Why is force-push to main/master always blocked? A: Rewriting history on a primary branch is almost always a mistake when done by an AI agent. Do it manually outside Claude Code if you truly need to.
Q: Can the AI read my private SSH key or .env?
A: Not without an explicit level-1+ override. Public keys (*.pub) and ~/.ssh/config are always allowed. This now also covers the Bash path — cat/base64/cp-source/dd if=/xxd/head on a protected path are blocked, not just the Read tool. It also covers directory-level exfiltration: handing a whole credential directory to a recursive reader (tar/zip/rsync/gpg/scp/grep -r ~/.ssh …) is blocked even though no individual key file is named, while metadata-only commands (ls/find/stat on the directory) stay allowed. The Bash check resolves direct path references; variable indirection (X=key; cat $X) and interpreter string literals (python -c "open(...)") stay outside its scope — the same inherent limit as blocked_patterns. It's defense-in-depth covering the realistic attack path, not a watertight guarantee.
Q: Does prompt-injection detection block anything? A: No — it only writes a warning to stderr. It's a heads-up, not a hard block.
Issues and PRs welcome. If you've been bitten by a similar incident and have patterns to add to the blocklist, please share them.
Before opening a PR, run the test suite (no install needed — the tests isolate their own runtime state):
python3 tests/test_command_guard.py
python3 tests/test_freigabe_e2e.py
See INSTALL.md for how to verify a live installation.
Born from a real incident. Built to prevent the next one.
1000+ skills curated from Anthropic, Vercel, Stripe, and other engineering teams
Agent harness performance optimization with skills, instincts, memory, and security
Design enforcement with memory — keeps your UI consistent across a project
Detects 37 AI writing patterns and rewrites text with human rhythm across 5 voice profiles