A Claude Code hook that evaluates every tool call before execution. It acts as a PreToolUse hook, applying a multi-stage evaluation system to automatically allow safe commands, block dangerous ones, and defer ambiguous cases to an LLM judge.
Because it is a PreToolUse hook, its decisions layer under the project's .claude/settings.json: the permission precedence is deny > ask > sentinel decision > allow. A project can therefore force an ask (or deny) on a command sentinel would otherwise allow, and that rule still takes effect — sentinel's allow never short-circuits a project's ask/deny.
uv tool install .Then register the hooks with Claude Code:
claude-sentinel installThis adds claude-sentinel as a PreToolUse hook in ~/.claude/settings.json. A backup (settings.json.bak) is created automatically. Installing also removes any hook left by an earlier layout (e.g. a PermissionRequest entry) so only one sentinel hook fires.
To remove:
claude-sentinel uninstallEvery Bash command and file path (Read/Write/Edit/MultiEdit) is evaluated through a multi-stage pipeline:
stdin JSON → RULE_DENY → RULE_ASK → RULE_ALLOW → LLM_JUDGE / LLM_JUDGE_READ → stdout JSON
| Stage | Method | Speed | Description |
|---|---|---|---|
| RULE_DENY | Regex deny list | Instant | Blocks known-dangerous commands (e.g. sudo, rm -rf /, curl | bash) |
| RULE_ASK | Regex ask list | Instant | Prompts user confirmation for commands that need review (e.g. ssh, systemctl) |
| RULE_ALLOW | Regex allow list | Instant | Permits known-safe commands (e.g. ls, git status, make) |
| LLM_JUDGE | LLM judge (haiku) | ~2-5s | Evaluates ambiguous commands; inline code (node -e, python -c) lands here so the judge reads the code |
| LLM_JUDGE_READ | LLM judge + Read tool | ~5-15s | Runs when a command executes a script file outside the project; the judge reads the script before deciding |
Bash commands are not matched as a single string. A small in-house splitter (no external dependency) walks the command, tracks quoting and escaping, and finds every individual command inside pipelines (|, |&), lists (&&, ||, ;, &, newline), command substitutions ($(…), `…`), process substitutions (<(…), >(…)), subshells ((…)), and parameter expansions (${…}). Each segment is evaluated independently against DENY → ASK → ALLOW, and the overall decision is the strictest result:
deny > ask > llm_read > llm > allow
So cd infra && terraform apply -auto-approve is split into cd infra (allow) and terraform apply -auto-approve (ask), and the result is ask — the dangerous segment cannot be hidden behind a permissive prefix. If even one segment matches no rule, the command falls through to the LLM judge with the full original string for context.
The splitter only models what it needs to find command boundaries; constructs it does not handle (heredocs <<EOF, ANSI-C quoting $'…', case statements, unbalanced quotes/parens) are deferred to the LLM judge rather than punting to the human. Before falling through, the deny regex set is run against the full command string as a defense-in-depth pre-filter, so clear-cut dangerous patterns (e.g. sudo, rm -rf /, curl … | sh) are blocked even when the splitter cannot tokenize the command. A parser limitation can never silently allow a dangerous command — it can only widen the set of commands that flow through LLM evaluation, which itself falls back to ask on timeout or error.
Rules are matched twice for each segment: once against the original command, then against a normalized form. This lets a single rule like ^\s*git\s+(diff|status|...) match all of git diff, git -c color.ui=never diff, git --no-pager diff, and git -C /tmp/repo diff without enumerating every wrapper. Stripping is whitelist-only — for each known program (git, npm, yarn, pnpm, bun, uv, cargo, go, make, docker, gh, kubectl, aws, gcloud) only documented prefix options like -c, -C, --no-pager, --silent, -q, -R, -j, --config, --region are removed. An unknown option halts stripping (safe fallback), and options after the subcommand (git push --force, npm run test --silent) are never touched.
Normalization also strips leading wrapper tokens that would otherwise let a dangerous command slip past a start-anchored rule: the ! negation and the do/then/else/command/exec/builtin/time/nohup prefixes, plus argument-taking command runners (env [VAR=val]…, timeout [opts] DURATION, nice, ionice, stdbuf). So ! kill -0 1 matches the kill ASK rule, do rm -rf "$x" (a loop body) matches the recursive-rm ASK rule, ! rm -rf / matches rm-rf-root, and env sudo apt / timeout 60 kill -1 match the sudo / kill-broadcast DENY rules. Condition keywords (while/until/for/if/case) are left intact. The same normalization is applied to ALLOW, ASK, and DENY matching, so none of these prefixes can be used to slip past confirmation rules.
Inline scripts are recursed into. A bash -c "<script>" / sh -c / eval "<script>" argument would otherwise be one opaque segment matched by the permissive shell-run allow rule, hiding whatever the script does. The splitter dequotes the inline script and evaluates its commands under the same rules, so bash -c "while true; do gh pr comment 1; done" is denied like a bare loop. If the inner script is itself unparseable, the whole command is routed to the deny prefilter and the LLM judge rather than auto-allowed.
Loops. while/until and C-style for (( … )) are denied (unbounded). A for over a literal list (for pr in 1 2 3) is allowed; a for whose iterator is a command substitution (for i in $(seq …)) is left for the LLM judge, which denies unbounded/side-effecting fan-out.
Interpreters are not blanket-allowed. node, python, ruby, perl, and the shells run arbitrary code, so their broad allow rules (node-run, python-run, zsh-run) are intercepted before they fire:
- Inline code —
node -e/-p/--eval/--print,python -c,ruby -e,perl -e— is routed toLLM_JUDGE. The code lives in the command string, so the judge reads it directly. (This closes the gap wherenode -e 'process.kill(…)'was auto-allowed.) - A script file outside the project — e.g.
bash /tmp/x.sh,node /tmp/y.js— is routed toLLM_JUDGE_READ. The script path is resolved against the working directory; anything not under it triggers a judge that is granted the built-inReadtool (scoped to the script's directory) to inspect the file before deciding. Scripts inside the project stay auto-allowed as ordinary local development.
File tools (Read, Write, Edit, MultiEdit) are protected in two layers. The PreToolUse hook fires on every tool call, so its sensitive-path evaluation now sees even calls a permissions.allow rule would auto-approve — but the hook is not guaranteed to fire on the sub-agent and background paths, so the settings.json layer is kept as defense-in-depth:
permissions.denyentries insettings.json(generated by the installer from thepath_globfield of each sensitive path rule, e.g.Read(**/.env),Edit(**/.ssh/**)). OnlyRead(...)andEdit(...)rules are generated — Claude Code ignoresWrite(...)file-permission deny rules, and itsEdit(...)rules already cover every file-editing tool (Write/Edit/MultiEdit/NotebookEdit). Deny rules are evaluated before allow rules and before the hook, so they block sensitive paths even though the installer also adds the bare tools topermissions.allowto keep ordinary in-project edits prompt-free.- The hook's sensitive path evaluation (
path_regex) — reads outside the working directory and specially-protected files like.claude/settings.json.
Read-only tools with no side effects (Grep, Glob, WebFetch, WebSearch, Slack read/search tools, Notion read/query tools) are auto-allowed without evaluation.
Tools with external impact (e.g. Slack send/schedule/canvas tools, Notion create/update/duplicate/move tools) require user confirmation (ASK).
| Rule | Pattern |
|---|---|
rm-rf-root |
rm -rf /, rm -rf ~, rm -rf $HOME |
sudo |
Any command starting with sudo |
fork-bomb |
:(){ :|:& };: pattern |
busy-wait-noop |
A loop body that is only a no-op (do :, do true, do continue) — an infinite CPU spin |
while-loop / until-loop |
while/until loops — a single approval cannot bound how many times a side-effecting body runs |
for-cstyle |
C-style for (( … )) (e.g. for (( ; ; ))) — unbounded loop. List-form for x in … stays allowed |
watch |
watch <cmd> — repeats a command indefinitely |
mkfs |
mkfs / mkfs.ext4 etc. |
dd-zero |
dd if=/dev/zero or /dev/urandom |
pipe-to-shell |
curl | bash, wget | sh |
force-push-main |
git push --force / -f to main/master (allows --force-with-lease) |
refspec-force-push-main |
git push origin +main refspec force push |
push-delete-main / refspec-delete-main |
git push --delete origin main, git push origin :main |
env-write |
Writing to .env files via >, >>, tee (template files .env.example/.sample/.template/.dist are exempt) |
dynamic-linker-hijack |
Setting LD_PRELOAD, LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH |
Sensitive files blocked from Read, Write, Edit, and MultiEdit tools. Each rule carries both a path_regex (used by the hook and by the sed -i backdoor check below) and a path_glob list (expanded by the installer into permissions.deny entries in settings.json, which is what protects in-project files — see "How it works"). The same patterns also deny an in-place sed -i / sed --in-place whose target file matches one of them — otherwise a bash command would be a backdoor around the protection the file tools enforce:
| Category | Rule | Pattern |
|---|---|---|
| Env/config | env-files |
.env, .env.* (committed templates .env.example/.sample/.template/.dist are exempt; the glob is only **/.env, so suffixed secret files rely on the hook path_regex) |
| Env/config | envrc |
.envrc |
| Env/config | secrets-files |
secrets.{yml,yaml,json,toml} |
| Env/config | terraform-vars |
terraform.tfvars, terraform.tfvars.json |
| SSH/keys | ssh-dir |
.ssh/* |
| SSH/keys | gnupg-dir |
.gnupg/* |
| SSH/keys | private-key-files |
*.pem, *.key (excludes *.pub.pem) |
| SSH/keys | keystore-files |
*.p12, *.pfx, *.jks, *.keystore |
| Cloud | aws-dir |
.aws/* |
| Cloud | gcloud-dir |
.config/gcloud/* |
| Cloud | azure-dir |
.azure/* |
| Cloud | credentials-json |
credentials.json, client_secret.json, service[-_]account*.json |
| Cloud | terraform-rc |
.terraformrc |
| Container | docker-config |
.docker/config.json |
| Container | kube-config |
.kube/config |
| Dev tools | netrc |
.netrc |
| Dev tools | npmrc |
.npmrc |
| Dev tools | pypirc |
.pypirc |
| Dev tools | gh-hosts |
.config/gh/hosts.yml |
| Dev tools | maven-settings |
.m2/settings.xml |
| Dev tools | gradle-properties |
.gradle/gradle.properties |
| Dev tools | boto-config |
.boto, .s3cfg |
| Database | pgpass |
.pgpass |
| Database | mycnf |
.my.cnf |
| Other | htpasswd |
.htpasswd |
| Other | vault-token |
.vault-token |
See src/claude_sentinel/rules/deny.toml for exact regex patterns.
Read-only tools with no side effects are automatically allowed without rule evaluation:
Grep— content searchGlob— file pattern matchingSearch— searchWebFetch— fetch web contentWebSearch— web searchmcp__claude_ai_Slack__slack_read_*— Slack read toolsmcp__claude_ai_Slack__slack_search_*— Slack search toolsmcp__claude_ai_Notion__notion-fetch/notion-search— Notion fetch/search toolsmcp__claude_ai_Notion__notion-get-*— Notion get tools (comments, teams, users, async task)mcp__claude_ai_Notion__notion-query-*— Notion query tools (data sources, views, meeting notes)mcp__claude_ai_Notion__notion-download-*— Notion attachment download
Additionally, file tools are added to permissions.allow so ordinary edits never prompt; sensitive paths are blocked by the generated permissions.deny entries, and calls that still reach the hook (outside-cwd, .claude/ files) are checked against the sensitive path rules:
Read— file readingWrite— file writingEdit— file editingMultiEdit— multi-edit
Common development commands are auto-approved, including:
- Shell: Bash comments (
#), shell constructs (for,while,if,case,do/done,then/else/fi,esac),pushd/popd,zsh/bash/shinvocations - File operations:
ls,cat,head,tail,find,grep,cp,mv,mkdir,touch,rm(non-recursive only;rm -r/rm -rfrequire confirmation),trash - Git:
status,log,diff,add,commit,revert,push(with--force-with-lease), etc. (destructive ops likereset --hard,checkout --,cleanrequire confirmation) - Build tools:
make(any target; dangerous targets likedeploy/publish/release/push/upgrade/tf-*/terraform-*are escalated to ASK),cargo(safe subcommands only),go build,node,bun(excludesbun x),python,uv(excludespublish),pipread (show/list/freeze/check/search/config get) - Package managers:
npm/yarn/pnpm(safe subcommands only, excludespublish;runallowstest/build/lint/cli/etc., excludesdeploy/publish/release/push);npx/pnpx/bunxfor safe dev tools (prettier,tsc,eslint,biome,prisma,vitest,jest,playwright,shadcn,next,vite, etc.; unknown packages require confirmation) - Containers:
docker(safe subcommands only, excludespush;docker compose exec/runrequire confirmation) - Database:
sqlite3 - Network:
curl/wget(excludes pipe-to-shell, POST/PUT/DELETE/PATCH methods, and--dataflags) - Cloud:
awsread operations (list,describe,get,show,wait),gcloudread operations (includinglogging readandlogging tail) - macOS:
launchctlread operations (list,print,blame),plutilread (-p,-lint),sample(process profiling),defaults read,mdfind(Spotlight),log show(unified log),fswatch(filesystem events),crontab -l,atq - Process inspection:
ps,pgrep,lsof - Utilities:
echo,pwd,which,date,sort,sed(sed -ion a sensitive path is denied; see below),awk,tar,zip,zipinfo,stat,env,printenv - Variable assignments:
VAR='value',VAR="value",VAR=word(static values only;VAR=$(...)is split and its inner command is evaluated independently) export VAR=...(the inner$(...)is split out and evaluated independently)
See src/claude_sentinel/rules/allow.toml for the full list.
Commands that prompt user confirmation without LLM evaluation:
rm -r/rm -rf/rm --recursive— recursive file deletiongit reset --hard— discard uncommitted changesgit checkout -- <path>— discard file changesgit clean— delete untracked filesosascript— AppleScript execution (GUI control, keystrokes)docker compose exec/docker compose run— arbitrary command execution in containersnpx/pnpx/bunx— arbitrary package execution (safe dev tools likeprettier,tsc,eslintare auto-allowed)bun x— arbitrary package executionxargs rm/xargs kill/ etc. — piped destructive commandseval/source/.— indirect command execution from variables or filespkill/killall/kill— process terminationssh— remote connectionssystemctl— system service managementlaunchctl load/unload/bootstrapetc. — macOS service mutationscrontab -e/crontab -r— crontab editing/removaldeploy— any command containing "deploy"make deploy/make tf-*/make terraform-*— infrastructure targetsmake publish/release/push— external-impact make targets (with hyphenated variants)make upgrade— upgrade targets (with hyphenated variants)terraform apply/destroy— infrastructure mutationspulumi up/destroy— infrastructure mutationskubectl apply/delete/createetc. — Kubernetes mutationshelm install/upgrade/uninstall/rollback— Helm mutationsnpm publish/cargo publish/uv publish/gem push/twine upload— package publishingdocker push— container registry pushgh pr create/merge/close,gh issue create,gh release create,gh repo createetc. — GitHub mutationsgh api ... -X POST/PUT/DELETE/PATCH— GitHub API mutationsgit push --force/-f,git push origin +branch,git push --delete/origin :branch(non-main;--force-with-leaseis allowed)curl/wgetwith-X POST/PUT/DELETE/PATCHor--dataflags — HTTP mutations. Mutations aimed exclusively at a literal loopback host (localhost,127.0.0.1,[::1]) skip ASK and defer to the LLM judge instead, provided the command contains no other host (including bare scheme-less args likeevil.com, which curl would POST to), no$/backtick expansion, and no reroute/second-request flags (-L,--location,--resolve,--connect-to,--proxy,--preproxy,--interface,--dns-*,--socks*,--next,-x,-K,--config)gcloud ... create/delete/deploy/updateetc. — Google Cloud mutationsaws ...— AWS CLI (catch-all; read ops are allowed by ALLOW rules)npm run/yarn run/pnpm runwithmigrate/migration— database migrations- Slack send/schedule/canvas tools (TOOL_ASK)
- Notion write tools —
notion-create-*,notion-update-*,notion-duplicate-*,notion-move-*(TOOL_ASK)
See src/claude_sentinel/rules/ask.toml for the full list.
Reads JSON from stdin and writes the hook response to stdout. This is how Claude Code invokes it:
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"},"session_id":"s","cwd":"/tmp"}' | claude-sentinelEvaluate a command without the full hook protocol:
claude-sentinel --test "ls -la"
# ALLOW [RULE_ALLOW]: Allowed by rule: ls
claude-sentinel --test "sudo rm -rf /"
# DENY [RULE_DENY]: Blocked by deny rule: rm-rf-rootAdd --explain to print the decision reason to stderr:
claude-sentinel --test "ls -la" --explainDisplay all loaded rules:
claude-sentinel rules # Show all rules
claude-sentinel rules --kind deny # Deny rules only
claude-sentinel rules --type Read # Read tool rules only
claude-sentinel rules --kind deny --type Read # Combined filter
claude-sentinel rules --json # JSON Lines outputclaude-sentinel install # Add hooks to ~/.claude/settings.json
claude-sentinel uninstall # Remove hooks from ~/.claude/settings.jsonWhen a command matches neither deny, allow, nor ask rules — or is an interpreter escalation (see above) — claude-sentinel evaluates it with the Claude Agent SDK (claude-haiku-4-5-20251001):
LLM_JUDGE(no tools,max_turns=2) — the default. The judge sees only the command string.LLM_JUDGE_READ(built-inReadtool,max_turns=6, scoped to the out-of-project script's directory viaadd_dirs) — used when the command runs a script file outside the project, so the judge can read the script before deciding.
The LLM responds with ALLOW, DENY, or ASK. Clearly dangerous or runaway commands (system-wide data loss, secret exfiltration, killing unowned processes, infinite/busy-wait loops, unbounded repeated external mutations) are denied outright; commands with a single bounded external impact that a human should confirm (publishing, deploying, one-off external mutations) are asked. On timeout, an incomplete result (error_max_turns), or any error the decision falls back to ASK, which prompts the user for manual approval.
src/claude_sentinel/
├── cli.py # Entry point, argparse
├── evaluator.py # Multi-stage evaluation engine
├── hook_io.py # stdin/stdout JSON handling
├── rule_engine.py # TOML rule loading and regex matching
├── command_normalizer.py # Strip prefix options before matching/grouping
├── llm_judge.py # LLM_JUDGE/LLM_JUDGE_READ: Claude Agent SDK judge
├── applier.py # Append validated rules to allow/ask.toml
├── logger.py # Evaluation log writer/reader
├── installer.py # Hook install/uninstall
└── rules/
├── deny.toml # RULE_DENY patterns
├── allow.toml # RULE_ALLOW patterns
├── ask.toml # RULE_ASK patterns
├── llm_prompt.txt # LLM_JUDGE prompt template
└── llm_prompt_read.txt # LLM_JUDGE_READ prompt template (reads out-of-project scripts)
Rule maintenance is driven by an interactive Claude Code slash command:
.claude/commands/update-rules.md # /update-rules — interactive rule proposer
# Install dev dependencies (ruff, pyright, pytest)
make install
# Run all checks (lint, format, typecheck, test)
make check
# Individual targets
make lint # Run linter (ruff check)
make lint-fix # Run linter with auto-fix
make fmt # Format code (ruff format)
make fmt-check # Check code formatting
make typecheck # Run type checker (pyright)
make test # Run tests (pytest)
make clean # Remove build artifacts and caches
# Rule maintenance (interactive, opens Claude Code)
make update-rules
# Test a command locally
uv run claude-sentinel --test "your-command-here"When LLM_JUDGE fallthroughs pile up in the evaluation log, refresh allow.toml / ask.toml interactively:
make update-ruleslaunches Claude Code in plan mode with the first prompt set to/update-rules— equivalent to runningclaude --permission-mode plan -- "/update-rules". (You can also startclaudeyourself and type/update-rulesmanually.) The slash command is defined in.claude/commands/update-rules.mdand drives Claude through the workflow: fetch the LLM_JUDGE log viaclaude-sentinel log --json, fetch existing rules viaclaude-sentinel rules --json, group records by intent (not surface form), and propose ALLOW / ASK candidates with rationale and decision tally.- Iterate. Tell Claude things like "drop #3", "narrow #5 to
make test:*", "split #7 into two rules", "this should be ASK not ALLOW". Claude refines until you say "apply". - On approval Claude edits the TOML files directly, inserting each new rule into the appropriate
# --- Title Case Section Name ---section ofallow.toml/ask.toml(or creating a new section when none fits), and adds matching assertions totests/test_rules.py.deny.tomlis never edited automatically — DENY candidates are surfaced for manual review only. - Claude runs
make checkto confirm the new rules and tests pass, then showsgit diff src/claude_sentinel/rules/ tests/test_rules.py. Revert anything you disagree with (git checkout -- <file>). - Commit the approved changes.
Lower-level pieces if you want them:
claude-sentinel log --stage LLM_JUDGE --since 30d -n 200 --json— raw fallthrough records.claude-sentinel rules --json— current rule snapshot.
DENY rule additions are never applied automatically. The slash command surfaces DENY candidates so you can add them to deny.toml by hand.
Requires Python 3.11+ (uses tomllib from the standard library). The only runtime dependency is claude-agent-sdk (used by the LLM judge stage); the rule engine and the bash splitter have zero external dependencies.
Works on macOS, Linux, and Windows.
- Sensitive path rules match both Unix (
/) and Windows (\) path separators - Logs are stored in
~/.local/share/claude-sentinel/logs/on Unix,%LOCALAPPDATA%\claude-sentinel\logs\on Windows (override withCLAUDE_SENTINEL_LOG_DIR) - Settings are read from
~/.claude/settings.jsonon all platforms