Safety Guard
July 24, 2026 · View on GitHub
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.
- Claude Code: runs as a
PreToolUsehook. - opencode: runs as a plugin that bridges to the same guard — see opencode/README.md.
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.
Quickstart
# 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.
What's new in v2
If you used the earlier version, here is what changed:
- Six tool matchers instead of two. The hook now guards
Bash,Read,Write,Edit,MultiEdit, andNotebookEdit. The write tools are checked against protected paths and self-protection — they were previously a blind spot. - Real level differentiation. The hook now evaluates
override_level0/1/2/3 individually. It is no longer "any override active → everything allowed". Paths and sudo are unlocked per level. - Agent scoping. Subagents do not inherit the coordinator's or main session's override. Each agent needs its own override file bound to its
agent_id, or it runs at level 0. - Self-protection. The hook, the rules file, the rules document, the active override directory and the
bin/scripts can never be written by the AI — no override lifts this. - Approval channel. The AI can no longer write itself an override. It writes a proposal; only the owner activates it via an owner-exclusive script invoked through
!. - Dev mode. A supervised, time-boxed way for the owner to let the AI edit the hook sources — without opening up settings, the override dir, or the rules.
- Fail-closed (breaking change). When the rules file or input is missing/unparsable, the hook now behaves conservatively (blocks / discards the override) instead of allowing through.
- Audit log. Every decision is written to a JSONL audit log with secret redaction.
expires_attimestamps. 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.
Features
- Blocked patterns —
rm -rf /,mkfs,chmod 777, fork bombs, pipe-to-shell, recursivechown/chmod/chgrpon system paths. Always blocked, even with an active override. - Git safety —
git reset --hard, force-push (incl.--force-with-lease),commit --no-verify,commit --amend,git add -A/git add ., and writinggit configare always blocked. Force-push tomain/masterhas its own dedicated rule on top. - Self-protection — The AI cannot write the hook, the rules file, the rules document, the active override directory, or the
bin/scripts — via Bash or Write/Edit. No override lifts this. - Owner-only commands — The approval and dev-mode scripts are hard-blocked for AI Bash calls, so the AI cannot grant itself rights.
- Protected paths — Level-dependent write protection for
~/.ssh,~/.gnupg,/etc/shadow,/boot,/usr/bin, etc. - Credential &
.envread protection — The Read tool cannot reach private keys, cloud credentials, or.envfiles without a level-1+ override. Public keys and SSH config stay open. - 3-level, agent-scoped override system — Scoped, explicit, auditable, and per-instance.
- Audit log — Every allow/block decision is logged (JSONL) with secret redaction.
- Desktop notifications — Optional heads-up on package installs.
- Prompt injection detection — Warns (doesn't block) when suspicious keywords appear in a command.
How it works
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.
Bash checks, in order
The first group runs always — an active override never weakens it:
- Blocked patterns (
rm -rf /,mkfs, recursivechownon system paths, …) - Owner-only commands (
grant-override,hook-dev-mode) - Force-push to
main/master - Git-safety ops (
reset --hard,--no-verify,--amend,add -A/., writingconfig) - Self-protection — write access to the security system's own files
Only after those does the hook load the override for the calling context and run the level-dependent checks:
- Protected paths — level 0: none; level 1: only explicitly granted paths; level 2+: all protected paths (single ops — recursive-on-system stays hard-blocked above)
- Sudo allowlist — base allowlist plus
additional_sudogrants; level 2+ (oradditional_sudo: "all") allows all sudo - Confirmation desktop notification, then prompt-injection warning
Read checks
.envfiles require a level-1+ override- Always-blocked reads (
/etc/shadow,/etc/gshadow) — no override possible - Always-allowed files (public keys,
~/.ssh/config,known_hosts,authorized_keys) - Override-required files (private keys, cloud credentials) — need a level-1+ override
Write / Edit / MultiEdit / NotebookEdit checks
- Self-protection — no override lifts it (only dev mode unlocks the hook sources)
.envwrite — requires a level-1+ override- Protected paths — same level logic as the Bash path check
Protection scope
The guard protects zones and catastrophic patterns, not "every write or delete". Knowing this contract avoids surprise:
- In scope — zones: writes/deletes to a configured
blocked_paths_writepath (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. - In scope — catastrophic patterns: a small set of always-block regexes
(
rm -rf /,rm -rf ~,chmod 777,mkfs,dd of=/dev/sd…, fork bomb,curl … | sh, recursivechown/chmodon system trees, …). - Out of scope — default allow: anything outside those zones and patterns is
allowed by design.
rm -rf ~/Downloads/projectis not blocked — the guard is a guardrail against catastrophic and protected-zone operations, not a general "are you sure?" for ordinary file management.
Known limitations
- Symlinks are not resolved. Path matching is purely lexical
(
os.path.normpath, norealpath/readlink) — see the_norm_pathdocstring ("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 inBEFUND-guard-scope-symlink-2026-07-24.md(columns 3/4 of the test matrix). - The write-verb gate is a coarse (some write verb) AND (protected path present) test, not a strict verb→path binding.
The 3 levels
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.
Level 1 — EXTENDED (deployment, configuration)
- Allowed: write to explicitly granted paths (
grants.allowed_paths, path-boundary-exact); extra sudo commands (grants.additional_sudo); single-file ops on normally protected paths. - Not allowed: recursive operations on protected paths; operations on system paths (
/usr/,/lib/,/bin/,/sbin/);chown/chmodon/etc/beyond explicitly named files. - Explanation duty: WHAT + WHY.
Level 2 — FULL (system maintenance, security fixes)
- Additionally allowed: write access to all normally protected paths; all sudo commands; single-file ops on system paths (e.g.
/etc/ssh/sshd_config). - Not allowed: recursive operations on system paths — never.
- Approval friction:
--confirm FULLrequired when granting. - Explanation duty: WHAT + WHY + RISK + concrete ROLLBACK command.
Level 3 — CRITICAL (emergencies, maximum risk)
- Additionally allowed: recursive operations on non-system paths that could reach system paths via bind mounts.
- Mandatory preconditions: a snapshot created beforehand and its ID documented (
--snapshot <ID>);--confirm CRITICALand--snapshotwhen granting; a maximum runtime (--minutes, default 120, max 1440); double confirmation; foreground only, no background agent. - Explanation duty: full briefing before every command; wait for an explicit "Continue".
At all levels, the always-blocked group (blocked patterns, git-safety, self-protection, owner-only) stays in force — level 3 included.
Agent scoping
The hook reads agent_id from the PreToolUse stdin and uses it to pick the matching override file:
- Main session / coordinator → override files without an
agent_idfield. These require a mandatoryexpires_at(an override without expiry is discarded). - Subagent → only override files named
agent-<agent_id>.jsonwhoseagent_idmatches 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 approval channel
The AI cannot grant itself an override. The active override directory is self-protected, so the AI cannot write there. The flow is:
- The AI detects a need (something was blocked, or it knows it will be) and determines the lowest sufficient level and the minimal scope.
- The AI writes a proposal (
confirmed: false) into the pending directory~/.claude/.sudo-overrides-pending/— which is deliberately not self-protected. - The AI hands the owner a ready-to-copy command and explains level + scope:
! ~/.claude/bin/grant-override <id> --minutes N [--confirm LABEL] [--snapshot ID] - The owner runs it (their consent). Because
grant-overrideis an owner-only command, only the owner's!reaches it. The script setsconfirmed: true,label,expires_at,granted_at,granted_by, then moves the file into the active directory~/.claude/.sudo-overrides/. - The hook reads the activated file and grants the scope until
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).
Dev mode
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.
Self-protection
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.
Audit log
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.
Credential & .env read protection
The 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.
What's always blocked
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 |
Configuration reference
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.
Override file format
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. |
FAQ
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.
Contributing
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.
License
Born from a real incident. Built to prevent the next one.