Skip to content

Latest commit

 

History

19 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sentnel

A Claude Code PreToolUse hook that checks every tool call against a YAML rule file before it runs — and blocks the ones you've denied.

License: MIT Python 3

What it does

Claude Code lets you register hooks that run before each tool call. sentnel installs one Python script (hook.py) as a PreToolUse hook on all tools. Before Claude executes a Bash command, file read, or file write, the hook receives the tool call as JSON on stdin, matches it against a set of hardcoded static rules plus your editable rules.yaml, and either lets it through or exits with code 2 — which makes Claude Code block the call and show Claude the rule that fired. Every decision (allow and deny) is appended to a local SQLite database, so you get an audit trail of what your agent tried to do. No daemon, no network calls, no dependencies beyond Python 3 and PyYAML.

Claude Code
    │  tool call (JSON on stdin)
    ▼
sentnel hook.py  ── static rules, then rules.yaml (first match wins)
    │
    ├── allow (exit 0) ──▶ tool executes
    └── deny  (exit 2) ──▶ call blocked, Claude sees:
                           "Sentnel blocked this action — rule: <id>"

Quickstart

One-liner (clones to ~/.sentnel and registers the hook):

curl -sSL https://raw.githubusercontent.com/sentnelops/sentnel/main/setup.sh | bash

Or from a local clone ("sidecar mode" — the hook runs from wherever you cloned it):

git clone https://github.com/sentnelops/sentnel.git
cd sentnel
bash setup.sh

setup.sh does exactly this: installs pyyaml via pip3, creates audit.db (git-ignored), registers python3 <repo>/hook.py under hooks.PreToolUse in ~/.claude/settings.json (idempotent — running it twice is safe), then runs a smoke test that pipes a fake rm -rf through the hook and verifies it exits 2.

A rule looks like this (from the shipped rules.yaml):

rules:
  - id: block_rm
    match:
      tool: Bash
      patterns_any:
        - "rm "
        - "shutil.rmtree"
        - "git clean"
    action: deny

  - id: block_network_exfil
    match:
      tool: Bash
      patterns_any:
        - "curl "
        - "wget "
        - "requests.get"
    action: deny

When a rule fires, the hook prints a block decision on stdout and exits 2:

{"decision": "block", "reason": "Sentnel blocked this action — rule: block_rm"}

Claude Code hard-blocks the tool call and feeds the reason back to Claude. You can test it directly without Claude Code:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /test"}}' | python3 hook.py
# → {"decision": "block", "reason": "Sentnel blocked this action — rule: static_rm"}; exit code 2

rules.yaml is re-read on every tool call, so edits apply immediately — no restart.

How it works

Hook event. One event: PreToolUse, registered with an empty matcher (""), so it runs for every tool. There is no PostToolUse component.

Decision logic (hook.py):

  1. Parse the tool-call JSON from stdin. If parsing fails, block (exit 2).
  2. Evaluate ~11 static rules hardcoded in hook.py (deletion via rm/shutil/os.remove/find -delete/git clean, reads of .env/id_rsa/.aws/credentials-style paths, curl/wget and Python network calls, reverse-shell indicators, DROP TABLE, and writes to .mcp.json/.claude/settings.json). These run first and can't be changed by editing YAML.
  3. Then evaluate rules.yaml top to bottom. First matching rule wins.
  4. No match → allow (exit 0).

Matching is case-insensitive substring matching: patterns_any entries are tested against the command field of the tool input, path entries against the path field. No regex, no globs.

Logging. Every call — allowed or denied — is written to audit.db (SQLite, next to hook.py) in a single events table: ts (unix float), tool, decision, reason (rule id, or NULL on allow), and raw (the full tool-call JSON). Query it with:

sqlite3 audit.db "SELECT ts, tool, decision, reason FROM events ORDER BY ts DESC LIMIT 20;"

The CLAUDE.md in this repo is an intent-level prompt (hard rules against deletion, secret reads, and network calls). Claude Code auto-loads it only when you're working inside this repo's directory — the installer does not register it globally.

Limitations — read this before trusting it

  • This is a defense layer, not a security boundary. The hook runs in-process on your machine against string patterns. A determined or clever model can compose a command that no substring matches (base64, variable expansion, writing a script then running it). Treat it as a seatbelt, not a sandbox.
  • Substring matching means false positives too — the pattern "rm " blocks any command containing it, e.g. npm rm some-package or a grep for the literal string.
  • If rules.yaml is malformed, the hook logs the error to stderr and continues with static rules only — your custom rules silently stop applying until the YAML is fixed.
  • Only a stdin parse failure fails closed. An unexpected crash elsewhere in hook.py exits non-zero-but-not-2, which Claude Code does not treat as a block.
  • File rules match the file_path key Claude Code sends for Read/Write (legacy path is accepted too).

Rules reference

Fields actually parsed by hook.py:

Field Type Behavior
id string Rule name; reported in the block reason and logged to audit.db
match.tool string Exact tool name (Bash, Read, Write, …). If set and it doesn't match, the rule is skipped
match.patterns_any list of strings Case-insensitive substrings tested against tool_input.command; any hit fires the rule
match.pattern string Single case-insensitive substring tested against tool_input.command
match.path list of strings Case-insensitive substrings tested against tool_input.path; any hit fires the rule
action string deny blocks the call (exit 2). Any other value allows it — and since first match wins, a matching non-deny rule short-circuits later rules

Evaluation order: static rules in hook.py, then rules.yaml rules in file order. First match decides.

Uninstall

bash uninstall.sh

This removes the sentnel entry from hooks.PreToolUse in ~/.claude/settings.json (matching this repo's hook.py path) and leaves everything else in your settings untouched. Restart Claude Code to take effect. Your audit.db and the cloned repo stay on disk; delete them yourself if you want them gone.

Part of the SentnelOps project

SentnelOps is an AI agent runtime governance platform. It acts as an MCP firewall between AI agents (Claude Code, Cursor, custom agents) and the systems they access, giving every agent an identity, enforcing YAML policies on every tool call before it executes, and producing an audit-ready evidence trail. sentnel is the free, local, in-process complement for Claude Code specifically. More: sentnelops.com · Securing Claude Code · AI agent runtime governance

License

MIT — see LICENSE.

Releases

Packages

Contributors

Languages