State-based tool gating and cryptographic receipt system for Claude Code.
Sentinel enforces deterministic tool access controls outside the LLM's reasoning loop and produces tamper-evident audit trails of every tool execution. Unlike prompt-based guardrails, Sentinel cannot be bypassed by prompt injection, jailbreaks, or model misinterpretation — enforcement happens in code, not in context.
Sentinel intercepts every Claude Code tool call via hooks and enforces two security primitives:
A finite state machine (FSM) controls which tools are available at each workflow phase. The orchestration layer intercepts tool calls via Claude Code's PreToolUse hook and blocks any tool not in the current state's allowlist.
Claude wants to call Write
↓
PreToolUse hook → HTTP POST localhost:9800/gate
↓
Sentinel checks: current state is "planning"
planning allows: [Read, Glob, Grep, WebFetch, Agent, mcp__.*]
Write is NOT in that list → BLOCKED (exit 2)
↓
Claude Code prevents the tool from executing
The model cannot talk its way past this. A prompt injection that says "ignore all rules" still hits the HTTP hook, which still checks the allowlist in deterministic Python code.
Every tool execution produces an Ed25519-signed, SHA-256 hash-chained receipt proving the tool actually ran with specific inputs and outputs.
{
"id": "019d1238-481f-70fa-...",
"seq": 42,
"tool_name": "Edit",
"tool_input_hash": "sha256:a3f2...",
"tool_output_hash": "sha256:7b91...",
"state": "developing",
"prev_hash": "sha256:e4c1...",
"event": "gate_allow",
"signature": "ed25519:R2x5..."
}- Ed25519 signatures — each receipt is signed with a private key that never enters the LLM's context
- SHA-256 hash chain — each receipt commits to the hash of the previous receipt; modifying any entry breaks the chain
- LLMs cannot forge these — tokenization makes hash computation impossible for language models
Claude tool call
↓
PreToolUse hook → HTTP POST localhost:9800/gate
↓
Sentinel: check FSM state → tool in allowlist? → guards pass?
↓ allow/deny
If allowed → tool executes → PostToolUse hook → HTTP POST /receipt
↓
Sentinel: hash(input) + hash(output) → chain to prev receipt → Ed25519 sign → append JSONL
Components:
- HTTP server (aiohttp) — maintains FSM state, handles gating decisions, generates receipts
- CLI — init, start/stop, status, verify chain, audit trail
- MCP server — lets Claude query its own state and receipts via Model Context Protocol
pip install -e .
sentinel init
sentinel start --daemonEdit ~/.config/sentinel/sentinel.yaml:
fsm:
initial_state: "idle"
states:
idle:
description: "No active workflow"
allowed_tools: [".*"]
planning:
description: "Read-only exploration"
allowed_tools: ["Read", "Glob", "Grep", "WebFetch", "WebSearch", "Agent", "mcp__.*"]
developing:
description: "Full tool access"
allowed_tools: [".*"]
testing:
description: "Test execution only"
allowed_tools: ["Read", "Glob", "Grep", "Bash", "mcp__.*"]
reviewing:
description: "Read-only review"
allowed_tools: ["Read", "Glob", "Grep", "WebFetch", "mcp__.*"]
transitions:
- { from: idle, to: planning, trigger: manual }
- { from: idle, to: developing, trigger: manual }
- { from: planning, to: developing, trigger: manual }
- from: developing
to: testing
trigger: Bash
guards:
- field: command
pattern: "^(pnpm|npm)\\s+test"
- { from: testing, to: developing, trigger: manual }
- { from: developing, to: reviewing, trigger: manual }
- { from: "*", to: idle, trigger: manual }Tool allowlists use regex patterns. Guards match tool input fields against regex patterns to trigger automatic state transitions.
Add hooks to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": ".*",
"hooks": [{ "type": "http", "url": "http://127.0.0.1:9800/gate", "timeout": 5 }]
}
],
"PostToolUse": [
{
"matcher": ".*",
"hooks": [{ "type": "http", "url": "http://127.0.0.1:9800/receipt", "timeout": 5 }]
}
]
}
}Register the MCP server:
claude mcp add --scope user -t stdio sentinel -- python3 -m sentinel.mcp_serversentinel init # Generate keys, create config
sentinel start [--daemon] # Start the HTTP server
sentinel stop # Stop the server
sentinel status # Show server, FSM state, chain length
sentinel state # Detailed FSM state + available transitions
sentinel transition <state> # Manually change state
sentinel verify # Verify receipt chain integrity
sentinel audit [-n 20] # View receipt audit trail
sentinel install-hooks # Print hook config JSON
sentinel install-mcp # Print MCP registration command
When registered as an MCP server, Claude can query its own enforcement state:
get_state— current FSM state and transition countget_allowed_tools— tools available in current stateget_transitions— available state transitionsget_recent_receipts— recent audit trail entriesverify_chain— verify receipt chain integrityget_receipt— look up a specific receipt by ID
If the sentinel server is down, Claude Code treats HTTP hook connection errors as non-blocking — tools continue to work normally. Sentinel is a safety overlay, not a hard dependency. Missing receipts are detectable as gaps in the chain.
- HTTP hooks over command hooks — near-zero latency, server maintains state in memory
- JSONL over SQLite — append-only semantics match receipt chain model, trivially verifiable line by line
- Ed25519 over HMAC — externally verifiable (anyone with the public key can verify, no shared secret needed)
- UUIDv7 over UUIDv4 — time-ordered IDs sort chronologically without a separate index
- Atomic state writes —
os.replace()prevents corrupt state from partial writes
pip install -e ".[dev]"
pytest tests/ -v69 tests covering crypto operations, FSM engine, receipt chain integrity, HTTP server endpoints, and tamper detection.
MIT