DashClaw Hooks for Claude Code
September 15, 2026 · View on GitHub
Two Python hook scripts that connect Claude Code to your DashClaw governance policies. Tool calls are classified into semantic categories (execution, file_io, orchestration, interactive, mcp) and evaluated against your DashClaw guard before execution. MCP tool calls (mcp__*) are included, so connected-MCP actions like Gmail/Stripe/Calendar sends are governed too. After execution, the outcome is recorded as evidence. No SDK instrumentation or code changes required in your project. Just drop the hooks in and set your environment variables.
v2 Intelligence Module
Hooks now use the dashclaw_agent_intel Python module for semantic classification of tool calls. This module is vendored alongside the hooks and requires only the Python standard library (zero external dependencies).
The intelligence module comprises five submodules:
- bash_classifier: Parses shell commands and classifies intent (e.g., destructive, network, filesystem, git) with structured validation results.
- file_scanner: Scans file paths and content for security-sensitive patterns (secrets, credentials, env files, auth configs).
- tool_recognizer: Maps Claude Code tool names to semantic categories and determines governance scope.
- session_tracker: Tracks session state across tool calls (cumulative risk, failure counts, branch staleness).
- mcp_monitor: Monitors MCP server health, latency, and degradation signals.
Tool Governance Scope
v2 hooks classify every Claude Code tool into a semantic category and govern based on that category.
Default governed categories:
| Category | Example tools |
|---|---|
execution | Bash, BashBackground |
orchestration | Agent, Skill, TodoWrite |
file_io | Edit, Write, MultiEdit, NotebookEdit |
interactive | AskUserQuestion, SendUserMessage, SendMessage |
mcp | Any mcp__* tool call |
Default ungoverned categories:
| Category | Example tools |
|---|---|
search | Read, Glob, Grep, WebFetch, WebSearch |
system | EnterPlanMode, ExitPlanMode, Config, Sleep |
Configure which categories are governed via the DASHCLAW_GOVERNED_CATEGORIES environment variable (comma-separated list). Unknown tools that do not match any category fail-safe to governed.
Narrowing this is visible on the dashboard. A category you exclude never reaches the server at all — the hook exits before the guard call — so its tool calls are simply absent from
/decisions, which looks identical to "that agent did nothing". Since v5.20 the hook declares the gap on the calls it does still make, and any category dropped below the default raises a red Governance scope narrowed signal naming what is unwatched. That is a visibility guarantee, not an enforcement one: the variable lives on the agent's own machine, so a compromised client can still lie about its scope. It catches the case that actually happens — an honest agent misconfigured, or a typo that silently dropped a real category.
Claude Code routing note. Which tool calls reach the hook is decided by the
PreToolUse/PostToolUsematcher in.claude/settings.json, which ships asAgent|Task|Workflow|Bash|Edit|Write|MultiEdit|Skill|mcp__.*. So sub-agent spawns are governed (theAgenttool — namedTaskbefore Claude Code 2.1.63) and so are Workflow fan-outs (v4.3 — the spawn is guard-evaluated and recorded asorchestrationbefore the run starts), alongside Bash and file edits. Themcp__.*segment puts MCP tool calls inside the matcher too, so connected-MCP actions (Gmail/Stripe/Calendar sends, etc.) are intercepted by the hook path before execution.Skillinvocations fire the advisory skill auto-scan in PreToolUse (no PostToolUse entry — intentional).PreToolUsealso fires inside sub-agents, so a sub-agent's own Bash/Edit/Write calls are governed too and recorded with sub-agent provenance (see "Sub-agent governance & tracking" below). Still outside the default matcher:interactivetools — govern those via the SDK/MCP server or by adding the names to the matcher. (Codex and Hermes installers wire their own routing.)
Sub-agent governance & tracking
DashClaw governs and records delegated (sub-agent) work end to end on Claude Code:
- The spawn. Invoking the
Agenttool (or legacyTask) is a governedPreToolUsedecision: it hits/api/guardand is recorded as anorchestrationaction, so you can see, gate, or require approval for which sub-agents get spawned. - The sub-agent's own tool calls. Claude Code fires
PreToolUseinside sub-agents (the hook stdin carriesagent_idandagent_type), so a sub-agent's Bash/Edit/Write/MultiEdit calls are evaluated against the same policies as the parent. - Attribution — distinct fleet identities (default since v2.2). By default (
DASHCLAW_SUBAGENT_IDENTITY=distinct) each sub-agent type gets its own composedagent_id(<parent>:<type>, e.g.claude-code:explore) and appears as a distinct agent in/agents, grouped under its parent. Governance stays correct: pairing/identity lookups, agent-targeted policies, and agent-scoped x402 budgets all fall back to (or roll up to) the base parent, so a sub-agent inherits the parent's permissions and rules — and cannot escape the parent's budget — unless you pair the sub-agent id explicitly. Provenance rides along either way:agent_name=<parent>/<agent_type>,swarm_id= the session id (so the spawn and the delegated work group together in the decisions ledger and the Swarm view), andintel.subagent = { agent_id, agent_type }. - Legacy rollback. Set
DASHCLAW_SUBAGENT_IDENTITY=provenanceto restore the pre-v2.2 behavior: sub-agent actions keep the parent's governedagent_idand sub-agent identity rides only the provenance fields. Design + rollout:docs/rfcs/2026-06-01-subagent-fleet-identities.md.
Plugin-defined sub-agents can't carry their own hooks (a Claude Code security restriction), but the session-level matcher above still covers them.
Enriched Intel Context
The pretool hook builds an intel dict for every governed tool call and includes it in the guard request. This gives the guard server rich context for policy decisions.
The intel dict contains:
- bash: Intent classification, parsed command structure, and validation results (for Bash tools only).
- file: Security scan results for file paths and content patterns (for file_io tools).
- tool: Semantic category, governance permission, and tool metadata.
- mcp: MCP server health, latency, and degradation signals (for mcp tools).
- session: Cumulative session state including risk score, failure count, and branch info.
Example guard request with intel:
{
"agent_id": "claude-code",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build"
},
"tool_use_id": "toolu_abc123",
"intel": {
"bash": {
"intent": "destructive",
"parsed": {
"executable": "rm",
"args": ["-rf", "/tmp/build"]
},
"validations": ["recursive_delete", "force_flag"]
},
"tool": {
"category": "execution",
"governed": true
},
"session": {
"cumulative_risk": 42,
"failure_count": 0,
"branch": "feat/cleanup"
}
}
}
Installation
Recommended: one-command install
From the DashClaw repo root:
node scripts/install-hooks.mjs
# or, in any project that has DashClaw cloned alongside it:
node /path/to/DashClaw/scripts/install-hooks.mjs --target=.
This copies the hook scripts (dashclaw_pretool.py, dashclaw_posttool.py, dashclaw_stop.py, dashclaw_db_containment.py, enforcement_liveness_probe.py, dashclaw_scope_sync.py) and the vendored dashclaw_agent_intel/ Python module into .claude/hooks/, then merges the matching PreToolUse / PostToolUse / Stop / SessionStart entries into .claude/settings.json. Re-run after git pull to refresh.
Global capture across every project (capture-only)
To capture Claude Code sessions from every project on your machine — not only those with DashClaw installed locally — register a capture-only Stop hook once:
node scripts/install-hooks.mjs --global # add --dry-run to preview, --uninstall to remove
This adds a single Stop entry to ~/.claude/settings.json pointing at this repo's hooks/dashclaw_stop.py by absolute path. It is capture-only: no PreToolUse/PostToolUse governance runs for other projects (the Stop hook's token-attribution step no-ops when there are no governed actions to attribute against). The hook resolves DASHCLAW_BASE_URL and DASHCLAW_API_KEY from this repo's .env.local, so no secret is written into global config and git pull upgrades the hook automatically. Any third-party Stop hooks you already have are preserved.
Global governance across every project (out-of-the-box, incl. Docker)
Project-level .claude/settings.json hooks only load after you accept Claude Code's "Do you trust the files in this folder?" prompt — so in a fresh clone, a Docker container, or a headless run they silently never fire (a user-level Stop hook will still fire, which is the classic "Stop ran but Pre/PostToolUse didn't" symptom). To govern every project with no per-folder trust step, install the full set at the user level:
node scripts/install-hooks.mjs --global --governance # add --dry-run to preview, --uninstall to remove
This merges PreToolUse + PostToolUse + Stop into ~/.claude/settings.json, pointing at this repo's hooks/*.py by absolute path. User-level hooks are not gated by folder trust, so they fire out of the box — including in Docker/headless. No secret is written: the hooks read DASHCLAW_BASE_URL (or DASHCLAW_URL) + DASHCLAW_API_KEY from the environment (or this repo's .env.local) at runtime, and git pull upgrades them automatically.
Manual install
mkdir -p .claude/hooks
cp hooks/dashclaw_pretool.py .claude/hooks/
cp hooks/dashclaw_posttool.py .claude/hooks/
cp hooks/dashclaw_stop.py .claude/hooks/
cp hooks/dashclaw_db_containment.py .claude/hooks/
cp hooks/enforcement_liveness_probe.py .claude/hooks/
cp hooks/dashclaw_scope_sync.py .claude/hooks/
cp -r hooks/dashclaw_agent_intel .claude/hooks/
The intel module is required — dashclaw_pretool.py imports dashclaw_agent_intel for semantic tool classification, so omitting it causes an ImportError on the first governed tool call. dashclaw_db_containment.py is optional: both hooks import it defensively, and without it database containment simply never engages.
Then merge the hooks block from hooks/settings.json into your .claude/settings.json. If you do not have a settings file yet, copy it directly:
cp hooks/settings.json .claude/settings.json
The committed
settings.jsontemplate invokes the hooks through therun_hook.cjsnode shim, which probespython3thenpythonautomatically — no manual interpreter edit is needed on any platform.
Environment variables
export DASHCLAW_BASE_URL=https://your-dashclaw-instance.vercel.app
export DASHCLAW_API_KEY=your_api_key_here
export DASHCLAW_AGENT_ID=claude-code # optional, defaults to "claude-code"; a --agent-id flag on the hook command (installer-written) beats this
Smoke test
echo '{"tool_name":"Bash","tool_input":{"command":"echo hello"},"tool_use_id":"test_001","session_id":"smoke"}' \
| python .claude/hooks/dashclaw_pretool.py
If DashClaw is reachable, the hook evaluates the command against your guard policies. If the configured instance is unreachable, the default DASHCLAW_GUARD_UNAVAILABLE_POLICY=block stops the command; set warn or allow explicitly for a development fail-open posture.
Use
python3here if your system has nopythonon PATH. (Installed hooks don't have this concern — they run through therun_hook.cjsshim, which resolves the interpreter automatically.)
Token capture (Stop hook)
dashclaw_stop.py runs at the end of every assistant turn. It reads the session transcript, sums LLM token usage across that turn's assistant messages (with cache-read tokens weighted at 0.1× to match real Anthropic billing), and PATCHes tokens_in, tokens_out, and model onto each action_id the pretool opened during the turn. Cost is derived server-side from the configured pricing table.
The Stop hook also auto-closes any action still in status='running' at turn end (PostToolUse safety net) — terminal statuses written by PostToolUse are preserved, never overwritten.
Enforcement-liveness probe (SessionStart hook)
enforcement_liveness_probe.py is wired as the SessionStart hook (--source session-start). It tests the enforcement seam end to end: it drives a synthetic action through the selected PreToolUse hook entry and verdicts by observing whether the action executed, never by reading the decision ledger. Its verdict is filed to POST /api/enforcement-liveness.
The same run measures the selected host runtime with its fixed version command and computes a SHA-256 fingerprint over the selected hook source bytes, including the delegated canonical hook when an adapter is selected. These fields describe what the local probe observed. They are client-reported operational evidence, not remote attestation or proof that the reporting process was trustworthy.
To keep session start instant, the SessionStart entry point throttles itself to at most once per 12h (a marker file under ~/.dashclaw/liveness-probe/) and runs the actual probe in a DETACHED child — session start is never delayed or broken by it. It reads the same configuration as the other hooks (DASHCLAW_BASE_URL/DASHCLAW_URL, DASHCLAW_API_KEY). Set DASHCLAW_LIVENESS_PROBE_DISABLED=1 to turn it off without uninstalling. The installer wires it automatically (per-project and --global --governance). Run it manually any time with python hooks/enforcement_liveness_probe.py (or npm run liveness:probe).
One probe, three seams. --runtime names WHICH seam a run drove, and it also chooses which config the probe reads — each harness declares its hooks in its own file and its own format, so a run can only ever answer for its own runtime:
--runtime | config read | a veto looks like |
|---|---|---|
claude-code (default) | <project>/.claude/settings.json, then ~/.claude/settings.json | hook exits 2 |
codex | $CODEX_HOME/config.toml (default ~/.codex) | hook exits 2 |
hermes | $HERMES_HOME/config.yaml (default ~/.hermes) | hook exits 0 and writes {"decision": "block"} to stdout |
--settings <path> overrides the config resolution (the codex installer passes it explicitly). A config file that exists but cannot be parsed — including a missing tomllib/PyYAML — is reported in the verdict detail rather than being read as "no hook installed", because a silent "nothing installed" is the same false green the probe exists to catch. The int32 timer-overflow emulation (the v4.72.1 failure) applies to claude-code only: it was never observed on the other harnesses, and asserting it there could render a live seam broken.
Runtimes with no row above are not oversights. MCP, the Node and Python SDKs, and REST are cooperative surfaces per the enforcement boundary ADR — guard returns a decision and the caller chooses to honour it — so there is no seam to hold and nothing for a probe to verdict.
Scope sync (SessionStart hook)
dashclaw_scope_sync.py is a second, independent SessionStart hook. It reads the active role_constraint policies' blocked_tools and turns them into Claude Code permissions.deny rules at session start, so a blocked tool can't even be chosen by the harness — it's denied before the guard ever sees it.
Allowlists are server-enforced only. allowed_tools is intentionally NOT translated into permissions.deny: Claude Code's deny list can express "block these specific tools" but not "deny everything except" — expressing an allowlist that way would require enumerating and denying every tool Claude Code knows about, a list this hook does not control and cannot keep current. The server-side guard evaluator stays the enforcement backstop for allowlists.
The hook owns only the deny entries it wrote, tracked in .claude/.dashclaw-scope.json: on each run it removes entries it previously added that are no longer wanted, adds newly blocked tools, and leaves every other key in .claude/settings.local.json — including deny entries added by anyone else — untouched.
If the server is unreachable, the credentials are missing, or settings.local.json exists but can't be parsed, the hook leaves the existing deny list exactly as it is and exits cleanly; it never breaks a session on failure.
Common setup failures
- Plugin installed but nothing is governed.
claude plugin install dashclawships MCP tools + skills only — not these hooks (they're Python files needing Python on PATH, so they're intentionally not bundled). Install the governance hooks separately:node scripts/install-hooks.mjs(per-project) or--global --governance(user-level, fires everywhere). "Install the plugin" ≠ "install governance." - Hooks don't fire in a fresh clone / Docker / headless run. Claude Code's Folder Trust gate prevents a project
.claude/settings.json(and its hooks) from loading until you accept the workspace-trust prompt for that folder. A user-level~/.claudehook (e.g. the global Stop hook) still fires — hence "Stop ran but Pre/PostToolUse didn't." Fix: accept the trust prompt and restart the session, or install at the user level withnode scripts/install-hooks.mjs --global --governance(no trust gate). - Hook does nothing / no
[DashClaw]output. The hooks readDASHCLAW_BASE_URL(they now also acceptDASHCLAW_URLas a fallback) plusDASHCLAW_API_KEY. If exactly one is set, PreToolUse prints a one-linehalf-configuredwarning naming the missing var; if both are unset it stays silent by design (non-DashClaw users see nothing). - Every request answers 503
SCHEMA_NOT_INITIALIZED. The instance DB is on an old schema. Runnpm run db:migrateon the instance to apply the pending schema. (Instances older than v4.61.0 answer this as a misleading 401 "Invalid or missing API key".) - Hook warns
[Demo mode].DASHCLAW_BASE_URLpoints at the demo deployment. Repoint it at your own instance.
Configuration
| Variable | Required | Default | Description |
|---|---|---|---|
DASHCLAW_BASE_URL | Yes | -- | URL of your DashClaw instance |
DASHCLAW_API_KEY | Yes | -- | Operator API key from /settings |
DASHCLAW_AGENT_ID | No | claude-code | Identity for this agent in DashClaw. A --agent-id <id> flag on the hook command line (written by the harness installers since v2.2) takes precedence, so each harness on a machine reports its own identity even when this var is exported machine-wide. |
DASHCLAW_SUBAGENT_IDENTITY | No | distinct | distinct (default since v2.2) gives each sub-agent type its own composed agent_id (<parent>:<type>) — a distinct fleet agent; the server falls back to the parent's pairing/targeted policies and rolls agent-scoped x402 budgets up to the family base. provenance restores the legacy behavior (agent_id stays the parent; sub-agent identity rides the provenance fields only). |
DASHCLAW_HOOK_MODE | No | enforce | enforce blocks on policy violations. observe logs everything but never blocks. The hook reports this mode on every guard call and action record (enforcement_mode), so observe-mode agents surface as a red signal, a red banner on /approvals and /decisions, and a doctor warning with a fix. When an observe-mode block or approval gate executes anyway, PostToolUse stamps executed_despite on the row — the ledger renders it "Executed despite block," never identically to an enforced block. CAUTION: the hook loads .env files by walking up from the hook file's directory, so one observe override applies to every session using that hook install. |
DASHCLAW_REQUIRE_EXECUTION_CLAIMS | No | 0 | Staged rollout pin. Set 1 after upgrading the server to execution claim protocol 1. Without the pin, a server that omits claim negotiation uses the existing guard and approval flow, which does not guarantee a single execution attempt. Advertised claims always require an exact acknowledgement; unsupported or malformed advertised protocols block. Install live hooks from a stable release, because editing a checkout used by a harness changes its next tool call immediately. |
DASHCLAW_PERMISSION_MODE | No | danger | Permission mode passed to the guard for policy evaluation |
DASHCLAW_GOVERNED_CATEGORIES | No | execution,orchestration,file_io,interactive,mcp | Comma-separated list of tool categories that are governed |
DASHCLAW_GUARD_TIMEOUT | No | 5 | Timeout in seconds for the guard request. By default the guard makes one attempt (no retries); set DASHCLAW_GUARD_RETRIES=2 to restore the old three-attempt behavior. |
DASHCLAW_GUARD_RETRIES | No | 0 | Extra guard attempts after the first (0 = single attempt). Action create/update calls retry independently (2 retries = 3 attempts). |
DASHCLAW_GUARD_CONNECT_TIMEOUT | No | 2 | Timeout in seconds for the conditional TCP preflight used when a prior turn found the guard unreachable. |
DASHCLAW_SKILL_SCAN | No | 1 | Set 0 to disable the advisory skill auto-scan fired on Skill tool invocations. |
DASHCLAW_HOOK_DEBUG | No | unset | Set 1 to capture PostToolUse invocation breadcrumbs (useful when diagnosing missed PostToolUse events). |
DASHCLAW_BEHAVIOR_UPLOAD | No | unset (off) | Opt-in anonymized behavior-sample upload (1/true/yes); requires the server-side org setting too. |
DASHCLAW_BEHAVIOR_INSIGHTS | No | on | Set 0 to opt out of the throttled behavior-insights push from the Stop hook. |
DASHCLAW_TRACK_TEXT_TURNS | No | unset (off) | Set 1 to record synthetic conversation actions for text-only turns so their tokens land in analytics. |
DASHCLAW_GUARD_UNAVAILABLE_POLICY | No | block | Behavior when the guard is unreachable after retries. block fails closed (exits 2). warn prints a stderr warning and proceeds. allow prints a stderr notice and proceeds. All three paths still write the orphan log for backfill. |
DASHCLAW_EXECUTION_CLAIM_POLICY | No | proceed | Behavior when the execution claim cannot be resolved after the guard has already allowed the call. The claim is the ledger's exactly-once stamp, not the authorization, so proceed runs the tool call and records the action unclaimed. block refuses it instead, for an operator who wants exactly-once enforced even when the ledger cannot answer. A claim genuinely held by another attempt blocks under either setting. |
DASHCLAW_APPROVAL_TIMEOUT | No | 30 | Timeout in seconds when polling for operator approval |
DASHCLAW_DISABLE_DOTENV | No | unset | Test isolation escape hatch. When set to any truthy value, the hooks skip the .env walk so the subprocess only sees env vars the caller passes in. The hook test suite sets this. Never set this in production: it disables the standard .env.local and .env loading the install flow relies on. |
DASHCLAW_CONTAINMENT | No | 1 | Full kill switch for Containment Verdicts. 1 (default) redirects an eligible allow_contained tool call into a staged worktree. 0 disables containment locally even if the server still emits allow_contained (version skew, mixed hook versions) — the hook treats it as an interrupt instead of ever creating a worktree. |
DASHCLAW_CONTAINMENT_REWRITE | No | 1 | 1 (default) rewrites an eligible Edit/Write/MultiEdit call in place to target the containment worktree, transparent to the agent. 0 falls back to an instructive deny (exit 2) that tells the agent to re-run the call against the worktree path. On the file bases, Bash is never rewritten either way — the Bash tool input schema has no cwd field, so a file-contained Bash call always gets the instructive deny (Locked Decision 6, RFC containment-verdicts). The db_branch basis is a Bash rewrite: it redirects the command field itself (see Database containment below). |
DASHCLAW_CONTAINMENT_DIFF_CAP_BYTES | No | 1500000 | Byte cap on the staged-diff text uploaded as an artifact after a contained mutation (keeps a single artifact under the /api/artifacts request-body cap). |
NEON_API_KEY | No | unset | Enables database containment (basis db_branch). Without it the hook never advertises allow_contained:db, so an eligible database act lands on require_approval exactly as it does today. |
NEON_PROJECT_ID | No | unset | Optional: skips the project walk that resolves your ep-… endpoint to its project. |
DASHCLAW_DB_CONTAINMENT | No | 1 | DB-only kill switch. 0 disables database containment while leaving file containment on; DASHCLAW_CONTAINMENT=0 still disables both. |
DASHCLAW_DB_CONTAINMENT_TTL_HOURS | No | 72 | Expiry stamped on the contained Neon branch. Neon deletes the branch itself when it lapses — that expiry is the cleanup story, so an abandoned session leaves nothing behind. |
Behavior
The PreToolUse hook calls POST /api/guard before each governed tool executes. The guard returns one of five decisions:
-
allow: The tool proceeds. An action record is created for the evidence trail.
-
warn: The tool proceeds. A warning is printed to the Claude Code terminal. An action record is created.
-
block: In enforce mode, the tool is blocked and Claude Code sees the policy reason. In observe mode, the warning is logged but the tool proceeds — and because the tool ran despite a gating verdict, PostToolUse stamps
executed_despite: blockon the blocked row (F0): the ledger shows the block did not stop execution. -
allow_contained (Containment Verdicts): only ever returned to a caller that advertised
client_capabilities: ['allow_contained']in the guard call — this hook does so by default (DASHCLAW_CONTAINMENT=1). In enforce mode, an eligibleEdit/Write/MultiEdit/Bashcall is redirected into a per-session git worktree (.dashclaw/contained/<session_id>) instead of the working tree; the tool proceeds against the staged copy. ForEdit/Write/MultiEditthis is transparent (updatedInputrewrite) whenDASHCLAW_CONTAINMENT_REWRITE=1, or via an instructive deny otherwise. On the file bases,Bashalways gets the instructive deny regardless ofDASHCLAW_CONTAINMENT_REWRITE— the Bash tool input schema has nocwdfield, so its command text is never rewritten in place (Locked Decision 6). Thedb_branchbasis is the exception and rewrites the command itself (see Database containment below). After execution, PostToolUse posts the resultinggit diffas a capped artifact on the action. An operator later promotes (governed merge) or discards the staged change from/approvalsordashclaw contained apply. If the working directory is not a git repo, or containment is disabled locally (DASHCLAW_CONTAINMENT=0), the hook fails toward interruption and treats the call asrequire_approvalinstead of ever creating a worktree. In observe mode, the action is recorded but the tool proceeds unstaged.Second-person-gate caveat: the
operatoridentity is exempt from the separation-of-duties check on the containment resolve route (mirrors the accepted approvals precedent). If this hook authenticates with the bootstrapDASHCLAW_API_KEY, its actions are attributed tooperator, andoperatorcan promote its own contained work —SELF_APPROVAL_FORBIDDENnever fires. For containment to have a real second-person gate, install the hook with a database-backedapi_keyscredential rather than the bootstrap operator key. -
require_approval: In enforce mode, an action record is created in
pending_approvalstatus. The hook prints the action ID and a replay link, then polls for up to 30 seconds waiting for an operator to approve or deny. If approved, the tool proceeds. If denied or timed out, the tool is blocked. In observe mode, the action is recorded but the tool proceeds immediately, and PostToolUse stampsexecuted_despite: require_approvalon the pending row so the ledger shows the gate did not hold.
In enforce mode, a server advertising execution-claim protocol 1 must confirm a
fresh claim for the exact action, principal, current decision, and act before the
host tool is released. Approval and plan grants are consumed atomically at this
claim, not by guard evaluation. A rejected, malformed, or lost acknowledgement
blocks and is never retried automatically. During a server-first upgrade, a
response with no claim fields keeps the legacy guard and approval flow until
DASHCLAW_REQUIRE_EXECUTION_CLAIMS=1 is set. Any partial or unknown claim
advertisement blocks even when that strict pin is unset.
Script bodies are evidence
The PreToolUse hook attaches the tool's real input to the guard call as act evidence, so the server grades what the act does rather than the model's description of it. For a Bash call this is the command text. When that command runs a local script (node buy.mjs, python deploy.py, bash release.sh, ./task.mjs), the hook also attaches the script itself as act.script: the resolved path and an excerpt of its contents, capped at 6144 characters. The server grades that excerpt with the same classifier it runs on the inline command and folds the two by the same max rule, so node buy.mjs is a plain other by its command line and a spend by what the file actually does. Without the excerpt the command text is all the server sees, and a purchase hidden one file away grades as an ordinary shell call. A script that lives on a sensitive path (.env, keys, secrets/**) attaches its path only, never its contents. The OpenClaw plugin 1.6.3 attaches the same shape.
The PostToolUse hook runs after execution completes. It updates the action record with the outcome (completed or failed) and a summary of the output (up to 500 characters). The hook sends structured outcome_metadata including exit_code and error_type when applicable. Errors are classified into four types: timeout, permission, not_found, and runtime. The posttool hook never blocks.
Database containment (Neon)
The same containment shape for a Postgres mutation (docs/rfcs/2026-09-04-database-containment.md): when the guard answers allow_contained with containment.basis = "db_branch", the hook creates one ephemeral Neon branch per session, rewrites the Bash command to point at that branch (updatedInput — the production URL literal is swapped, or a DATABASE_URL='…' PGHOST='…' prefix is added), and PostToolUse posts the statement, the Neon schema diff and the last 4 KB of output as the patch artifact the operator reviews. Promote replays the original command against the real database; nothing is ever copied back.
Rollout safety, in the order it matters:
- No
NEON_API_KEY, no change. The capabilityallow_contained:dbis never advertised, so the server can never send the verdict and an eligible database act stays onrequire_approval— today's behavior, bit for bit. - Non-Neon databases are never contained. The target must resolve to an
ep-….neon.techhost, fromDATABASE_URLin the environment orDATABASE_URLin<workspace>/.env.localor.env(that one key, never logged, never uploaded). - A command with an inline connection string stays on the approval rail. The ledger's sensitive-data scan redacts a
postgres(ql)://user:pass@hostliteral inside the recorded act, and Promote replays that act byte-for-byte — so such a command could be staged and then never replayed, and the hook declines the capability for it rather than offering an unpromotable card. - Two kill switches.
DASHCLAW_DB_CONTAINMENT=0disables the database medium only;DASHCLAW_CONTAINMENT=0disables both media and treats anyallow_containedverdict as an interrupt. - Failure interrupts, never leaks. A missing server ref, an unresolvable target or a failed Neon call exits 2 with the command unrun, so a contained effect never reaches production unstaged. Log lines name the branch host; a connection URL with its password is never printed, never written to hook state and never uploaded.
- Cleanup is expiry. The branch carries an
expires_at(DASHCLAW_DB_CONTAINMENT_TTL_HOURS, default 72) and Neon deletes it; Promote and Discard need no Neon key. - Version skew only tightens. An older hook never advertises
allow_contained:db, so a newer server downgrades torequire_approval; an older server never sends the basis, so a newer hook stays on the worktree path.
If DashClaw is unconfigured (DASHCLAW_BASE_URL or DASHCLAW_API_KEY missing), the hooks exit silently and Claude Code operates normally. If DashClaw is configured but unreachable, behavior is governed by DASHCLAW_GUARD_UNAVAILABLE_POLICY (default block). See the Failure safety section below for the full policy table. The hooks never crash your session.
Failure safety
If DASHCLAW_BASE_URL or DASHCLAW_API_KEY is unset, both scripts exit 0 silently and Claude Code is never blocked.
If DashClaw is configured but the API is unreachable (timeout, network error, 5xx) — by default the guard makes a single attempt; set DASHCLAW_GUARD_RETRIES to add retries — behavior is governed by DASHCLAW_GUARD_UNAVAILABLE_POLICY:
| Policy value | Behavior on unreachable guard |
|---|---|
block (default) | Hook exits 2, the tool call is blocked, the action is logged to ~/.dashclaw/orphan-actions.jsonl for backfill when the guard recovers. |
warn | Hook prints a stderr warning, the action is logged, the tool proceeds. |
allow | Hook is silent, the action is logged, the tool proceeds. |
The block default is correct for production governance posture: destructive actions should not proceed without a guard check. For development environments or single operator setups, warn is often the better choice. Set it in your environment:
export DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn
Action create/update calls retry transient failures up to three attempts with 0.4 second and 0.8 second backoff, so most cold start blips on Vercel and Neon are absorbed automatically. The guard check itself makes a single attempt by default (it sits on the hot path before every governed tool call); set DASHCLAW_GUARD_RETRIES=2 on flaky networks to give it the same three-attempt behavior.
Threat model — what this hook is, honestly
These scripts run at the same privilege level as the agent they govern. The hook file, the intelligence module, and the PreToolUse registration in .claude/settings.json are all writable by any process that can write your home directory — including the governed agent itself. That makes the hook a seatbelt against accidents, not a cage against intent: it reliably stops the destructive command a busy model emits mid-run, and it makes tampering visible (a write to the hook path is itself a governed, recorded action; the enforcement-liveness probe reports the seam dead on its next run) — but it cannot stop a determined process executing at its own privilege.
What to do about it, in increasing order of strength:
- Control-plane policy (software, included). Keep a
protected_pathpolicy of typeblockwithrules.ungrantable: trueover**/.claude/settings.json,**/.claude/hooks/**, and your hooks directory. Self-disable becomes a blocked, logged act instead of a silent one. - Read-only hook path (deployment). Mount or ACL the hook directory and settings file read-only for the account the agent runs as.
- Separate OS user (deployment). Run the agent as a user with no write access to the hook path or its own harness config.
- Container (deployment). Run the agent in a container whose image owns the hook; the workspace is the only writable mount.
DashClaw ships 1 and documents 2–4; it cannot provide them for you. Full statement: docs/architecture/enforcement-boundary.md.
Composition detection (script-then-execute). Two individually-benign calls can compose into a destructive one: write a script whose payload would be blocked inline, then execute the script path (which grades as a routine interpreter call). The hook closes this: PostToolUse records every path the session writes (file tools, shell redirects, tee, curl -o/wget -O) in a per-session ledger, and PreToolUse grades the content of any recently-self-written script being executed with the same classifiers inline commands get — so the split form earns exactly the inline grade, and routine write-then-run workflows (bash cleanup.sh deleting build artifacts) keep their calibrated low score. A ledger hit whose content can't be read (missing/oversized) floors at the review band. The signal appears as script_then_execute in the decision's classifier signals on /decisions. Documented residuals: indirection (eval "$(< x.sh)"), cross-session splits, and writer flags the parser doesn't model — all inside the privilege boundary above. TTL is tunable via DASHCLAW_SCRIPT_EXEC_TTL_MINUTES (default 60). Spec: docs/plans/2026-08-06-script-then-execute-spec.md.
Approving from the terminal
When a tool call requires approval, the hook prints the action ID:
[DashClaw] Approval required
Action ID: act_abc123
Goal: Bash: git push origin main
...
Approve from terminal: dashclaw approve act_abc123
If you have the @dashclaw/cli package installed, run dashclaw approve act_abc123 from another terminal to approve inline. You can also approve from the DashClaw dashboard at /approvals. The replay link printed in the terminal (<DASHCLAW_BASE_URL>/replay/<action_id>) opens the full decision evidence in your browser.
Recovery Context
Guard responses now include an optional recovery field when the intel signals indicate a recoverable issue. When present, the recovery context contains a recipe type and suggested actions for the operator or agent.
Six recovery recipe types are supported:
| Recipe | Trigger |
|---|---|
session_stalled | Session has high failure count or repeated blocked actions |
branch_stale | Working branch is significantly behind the base branch |
mcp_degraded | One or more MCP servers report high latency or errors |
repeated_failures | The same tool or command has failed multiple times in sequence |
green_insufficient | Test coverage or passing rate has dropped below threshold |
assumption_drift | Agent behavior has diverged from the declared plan or goal |
The recovery field is informational. It does not block tool execution on its own but gives operators and agents structured guidance to self-correct.
What gets governed
All tools in governed categories are evaluated against DashClaw policies. With the default DASHCLAW_GOVERNED_CATEGORIES, this includes:
- execution: Bash, BashBackground. Shell commands are enriched with bash intent classification. Git operations, deployments, infrastructure commands, destructive operations, and HTTP calls get elevated risk scores.
- file_io: Edit, Write, MultiEdit, NotebookEdit. File operations are enriched with security scan results. Sensitive files (
.env, secrets, credentials), migrations, infrastructure configs, and auth-related files get elevated risk scores. - orchestration: Agent (plus the legacy
Taskalias), Skill, TodoWrite. TheAgent/Taskspawn tools are in the shipped matcher, so sub-agent spawns are governed and recorded asorchestrationactions (see "Sub-agent governance & tracking").Skill/TodoWriteare classified but not in the default matcher — add them if you want them intercepted. - interactive: AskUserQuestion, SendUserMessage, SendMessage. Prompt/interaction tools are governed by default.
- mcp: Any
mcp__*tool call. Enriched with server health signals.mcp__*is in the shipped matcher, so MCP tool calls are intercepted by thePreToolUsehook before execution (connected-MCP actions like Gmail/Stripe/Calendar sends). The DashClaw MCP server remains the governance path for hosts that don't run Claude Code hooks.
Unknown tools that do not match any configured category fail-safe to governed.
What does not get governed
- Tools in ungoverned categories: search (Read, Glob, Grep, WebFetch, WebSearch) and system (EnterPlanMode, ExitPlanMode, Config, Sleep) pass through without evaluation by default.
- Any tool call when
DASHCLAW_BASE_URLorDASHCLAW_API_KEYis not set.
Configured but unreachable behavior is controlled by DASHCLAW_GUARD_UNAVAILABLE_POLICY (see Failure safety above). With the default block policy, unreachable means the tool call is denied, not waived.
Replay
Every governed action creates a replayable evidence record in DashClaw. Visit <DASHCLAW_BASE_URL>/replay/<action_id> to see the full causal chain: what the agent intended, which policy was matched, whether approval was required, who approved it, and what the outcome was. This works for both allowed and blocked actions, giving operators a complete audit trail of what Claude Code did and why.