Security
Hook
Hooks
What dashclaw runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add ucsandman/DashClaw > /plugin install dashclaw@dashclaw
Ships with dashclaw. Installing the plugin gets these hooks.
Where it lives
- hooks/dashclaw_posttool.pyGitHub
Read the script
#!/usr/bin/env python3 """ DashClaw PostToolUse Hook v2 for Claude Code. Records the outcome of governed tool calls by updating the action record created by the PreToolUse hook. v2 adds richer outcome reporting: - 500-char output summaries (up from 200) - Structured outcome_metadata with exit_code, error_type classification - Improved error detection: checks exit code AND error field - Error classification: timeout, permission, not_found, runtime Never blocks. Always exits 0. """ import hashlib import json import os import re import shutil import subprocess import sys import tempfile import urllib.request import urllib.error from datetime import datetime, timezone # Import the shared HTTP retry helper from the sibling intel package. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from dashclaw_agent_intel.http_client import request_with_retry from dashclaw_agent_intel.stop_state import contained_turn_path as _contained_turn_path from dashclaw_agent_intel.command_parser import parse_command as _parse_command from dashclaw_agent_intel.written_paths_ledger import record_written_paths as _ledger_record # --------------------------------------------------------------------------- # Load .env file (C:/Projects/DashClaw/.env) before reading config. # Values already in the environment take precedence. # --------------------------------------------------------------------------- def _apply_env_line(line): line = line.strip() if not line or line.startswith("#") or "=" not in line: return key, _, val = line.partition("=") key = key.strip() val = val.strip().strip('"').strip("'") if " #" in val: val = val[:val.index(" #")].strip() if key and key not in os.environ: os.environ[key] = val def _apply_env_file(env_path): try: with open(env_path, encoding="utf-8") as f: for line in f: _apply_env_line(line) except FileNotFoundError: return def _load_dotenv(): # Test isolation escape hatch: when DASHCLAW_DISABLE_DOTENV is set, skip # the .env walk entirely so the subprocess only sees env vars the test # explicitly passed in. Never set this in production. if os.environ.get("DASHCLAW_DISABLE_DOTENV"): return # Walk up from the hook file's directory looking for env files. Works # whether this runs from hooks/X.py (project root is one parent up) or # from .claude/hooks/X.py after install-hooks runs (project root is two # parents up). Earlier files win because of `key not in os.environ`. tried = set() current = os.path.abspath(os.path.dirname(__file__)) for _ in range(5): for fname in (".env.local", ".env"): env_path = os.path.join(current, fname) if env_path in tried: continue tried.add(env_path) _apply_env_file(env_path) parent = os.path.dirname(current) if parent == current: break current = parent def _resolve_base_url(base_explicit, url_explicit): """Resolve DASHCLAW_BASE_URL/DASHCLAW_URL with explicit-env-beats-dotenv precedence: explicit BASE_URL > explicit URL > dotenv BASE_URL > dotenv URL. Mirrors dashclaw_pretool.py's _resolve_base_url -- see there for the 2026-07-27 incident this fixes. Both hooks must resolve identically so a tool call governed under one BASE_URL isn't PATCHed against another.""" base_val = os.environ.get("DASHCLAW_BASE_URL") or "" url_val = os.environ.get("DASHCLAW_URL") or "" if base_explicit and base_val: return base_val if url_explicit and url_val: if base_val and not base_explicit: sys.stderr.write( "[DashClaw] Explicit DASHCLAW_URL=%s overrides a .env-provided " "DASHCLAW_BASE_URL=%s\n" % (url_val, base_val) ) return url_val return base_val or url_val # Captured BEFORE _load_dotenv() fills gaps, so _resolve_base_url can tell an # explicitly-exported value apart from one that only exists because dotenv # populated it. _BASE_URL_EXPLICIT = "DASHCLAW_BASE_URL" in os.environ _URL_EXPLICIT = "DASHCLAW_URL" in os.environ _load_dotenv() # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- def _argv_agent_id(): # Mirrors dashclaw_pretool.py's _argv_agent_id: the harness integration # that wires this hook may append `--agent-id <id>` to the command line. # Needed here (not just for identity) so the instance-state hash below # matches pretool's when a harness passes the flag to both. argv = sys.argv[1:] for i, arg in enumerate(argv): if arg == "--agent-id" and i + 1 < len(argv): return argv[i + 1].strip() if arg.startswith("--agent-id="): return arg.split("=", 1)[1].strip() return "" BASE_URL = _resolve_base_url(_BASE_URL_EXPLICIT, _URL_EXPLICIT).rstrip("/") API_KEY = os.environ.get("DASHCLAW_API_KEY") or "" AGENT_ID = _argv_agent_id() or os.environ.get("DASHCLAW_AGENT_ID") or "claude-code" # Short stable hash of (resolved BASE_URL + AGENT_ID) -- must match # dashclaw_pretool.py's _INSTANCE_STATE_SUFFIX bit for bit so this reader only # ever finds the state its OWN installation's pretool wrote. See # dashclaw_pretool.py for the full rationale (2026-07-27 incident). _INSTANCE_STATE_SUFFIX = hashlib.sha256((BASE_URL + "|" + AGENT_ID).encode("utf-8")).hexdigest()[:12] # Set DASHCLAW_HOOK_DEBUG=1 in .env to capture PostToolUse invocation breadcrumbs # in <tempdir>/dashclaw_hook_errors.log. Useful for diagnosing why PostToolUse # isn't firing or is exiting early (missing tool_use_id, missing action_id, etc.) # — the miss rate for PostToolUse has historically been ~96% in the wild and the # root cause is opaque without this. DEBUG = (os.environ.get("DASHCLAW_HOOK_DEBUG") or "").strip() in (" - hooks/dashclaw_pretool.pyGitHub
Read the script
#!/usr/bin/env python3 """ DashClaw PreToolUse Hook v2 for Claude Code. Evaluates all 40+ agent tool calls against DashClaw guard policies using the dashclaw_agent_intel module for semantic classification. Exit codes: 0 - Allow the tool to proceed 2 - Block the tool (Claude Code shows stderr to user) """ import hashlib import json import os import re import shutil import socket import subprocess import sys import tempfile import time import urllib.error import urllib.parse import urllib.request # --------------------------------------------------------------------------- # Load .env file (C:/Projects/DashClaw/.env) before reading config. # Values already in the environment take precedence. # --------------------------------------------------------------------------- def _apply_env_line(line): line = line.strip() if not line or line.startswith("#") or "=" not in line: return key, _, val = line.partition("=") key = key.strip() val = val.strip().strip('"').strip("'") if " #" in val: val = val[:val.index(" #")].strip() if key and key not in os.environ: os.environ[key] = val def _apply_env_file(env_path): try: with open(env_path, encoding="utf-8") as f: for line in f: _apply_env_line(line) except FileNotFoundError: return def _load_dotenv(): # Test isolation escape hatch: when DASHCLAW_DISABLE_DOTENV is set, skip # the .env walk entirely so the subprocess only sees env vars the test # explicitly passed in. Never set this in production. if os.environ.get("DASHCLAW_DISABLE_DOTENV"): return # Walk up from the hook file's directory looking for env files. Works # whether this runs from hooks/X.py (project root is one parent up) or # from .claude/hooks/X.py after install-hooks runs (project root is two # parents up). Earlier files win because of `key not in os.environ`. tried = set() current = os.path.abspath(os.path.dirname(__file__)) for _ in range(5): for fname in (".env.local", ".env"): env_path = os.path.join(current, fname) if env_path in tried: continue tried.add(env_path) _apply_env_file(env_path) parent = os.path.dirname(current) if parent == current: break current = parent def _resolve_base_url(base_explicit, url_explicit): """Resolve DASHCLAW_BASE_URL/DASHCLAW_URL with explicit-env-beats-dotenv precedence: explicit BASE_URL > explicit URL > dotenv BASE_URL > dotenv URL. `base_explicit`/`url_explicit` say whether the process env already had that key set BEFORE _load_dotenv() ran (captured below). Without this, a repo's own .env setting DASHCLAW_BASE_URL silently wins over an explicitly-exported DASHCLAW_URL merely because BASE_URL is checked first in the naive `BASE_URL or URL` fallback -- the 2026-07-27 incident that misrouted three hook-triggered calls to a hosted production instance instead of the exported localhost URL.""" base_val = os.environ.get("DASHCLAW_BASE_URL") or "" url_val = os.environ.get("DASHCLAW_URL") or "" if base_explicit and base_val: return base_val if url_explicit and url_val: if base_val and not base_explicit: sys.stderr.write( "[DashClaw] Explicit DASHCLAW_URL=%s overrides a .env-provided " "DASHCLAW_BASE_URL=%s\n" % (url_val, base_val) ) return url_val return base_val or url_val # Captured BEFORE _load_dotenv() fills gaps, so _resolve_base_url can tell an # explicitly-exported value apart from one that only exists because dotenv # populated it. _BASE_URL_EXPLICIT = "DASHCLAW_BASE_URL" in os.environ _URL_EXPLICIT = "DASHCLAW_URL" in os.environ _load_dotenv() # --------------------------------------------------------------------------- # Import dashclaw_agent_intel (sibling directory) # --------------------------------------------------------------------------- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from dashclaw_agent_intel import classify_bash, scan_file_operation, classify_tool, McpHealthMonitor from dashclaw_agent_intel.bash_classifier import is_bounded_rm, is_regenerable_artifact_rm from dashclaw_agent_intel.written_paths_ledger import ( extract_exec_candidates, grade_script_content, lookup_written_path, ) from dashclaw_agent_intel.file_scanner import is_placeholder_path from dashclaw_agent_intel.tool_recognizer import ungoverned_default_categories from dashclaw_agent_intel.http_client import request_with_retry, env_retries # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- def _argv_agent_id(): # Per-harness identity declaration (roadmap v2.2). The harness integration # that wires this hook appends `--agent-id <id>` to the command line, so # identity is per-harness even when every harness on the machine shares # one script directory / .env / DASHCLAW_AGENT_ID export. argv beats env # by design: the flag is written by the installer that knows which # harness it is wiring; the env var is machine-ambient. argv = sys.argv[1:] for i, arg in enumerate(argv): if arg == "--agent-id" and i + 1 < len(argv): return argv[i + 1].strip() if arg.startswith("--agent-id="): return arg.split("=", 1)[1].strip() return "" BASE_URL = _resolve_base_url(_BASE_URL_EXPLICIT, _URL_EXPLICIT).rstrip("/") API_KEY = os.environ.get("DASHCLAW_API_KEY") or "" AGENT_ID = _argv_agent_id() or os.environ.get("DASHCLAW_AGENT_ID") or "claude-code" # Short stable hash of (resolved BASE_URL + AGENT_ID), used to namespace this # hook installation's tempdir state files from any OTHER DashClaw hook # installation that fires for the SAME Claude Code tool_us - hooks/dashclaw_stop.pyGitHub
Read the script
#!/usr/bin/env python3 """ DashClaw Stop Hook for Claude Code. Captures the assistant turn's LLM token usage from the session transcript and PATCHes it to the action records created during the turn. Cost is derived server-side from the configured model pricing table. Data flow: - PreToolUse (dashclaw_pretool.py) appends each new action_id to "dashclaw_turn_<session_id>" in the temp dir. - This Stop hook sums token usage across assistant messages that landed since the last Stop (tracked via "dashclaw_stop_cursor_<session_id>"), distributes the totals evenly across the turn's action_ids, and PATCHes each action with tokens_in, tokens_out, and model. The server then derives cost_estimate from its pricing table. This file is the orchestrator; the mechanics live in the sibling intel package (extracted in the health pass so each seam is unit-testable): - dashclaw_agent_intel.stop_transcript — transcript parsing, turn/usage math, tool_use collection, assumption extraction, distribution. - dashclaw_agent_intel.stop_state — tempdir session state (turn actions, cursor, posted-assumption keys, throttle markers). Never blocks. Always exits 0. """ import hashlib import json import os import re import sys import tempfile import urllib.request import urllib.error # Import the shared HTTP retry helper from the sibling intel package. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from dashclaw_agent_intel.http_client import request_with_retry from dashclaw_agent_intel.stop_state import ( log_hook_error as _log_hook_error, read_cursor as _read_cursor, write_cursor as _write_cursor, read_turn_actions as _read_turn_actions, clear_turn_actions as _clear_turn_actions, read_posted_assumption_keys as _read_posted_assumption_keys, append_posted_assumption_keys as _append_posted_assumption_keys, read_posted_deviation_keys as _read_posted_deviation_keys, append_posted_deviation_keys as _append_posted_deviation_keys, count_session_actions as _count_session_actions, read_contained_turn_actions as _read_contained_turn_actions, clear_contained_turn_actions as _clear_contained_turn_actions, read_posted_containment_keys as _read_posted_containment_keys, append_posted_containment_keys as _append_posted_containment_keys, ) from dashclaw_agent_intel.stop_transcript import ( load_entries as _load_entries, resolve_turn_start as _resolve_turn_start, collect_turn_usage as _collect_turn_usage, collect_turn_tool_uses as _collect_turn_tool_uses, is_governed_tool_name as _is_governed_tool_name, turn_assistant_text as _turn_assistant_text, extract_assumptions as _extract_assumptions, extract_deviations as _extract_deviations, distribute as _distribute, patch_body_for as _patch_body_for, datetime_now_iso, ) # --------------------------------------------------------------------------- # Env loading — pretool/posttool load from .env.local + .env before reading # DASHCLAW_* config; stop needs the same so tokens actually PATCH back # instead of hitting an empty URL with an empty API key. Without this, # the Stop hook silently fails for every session that doesn't inherit # the vars from the shell — which is most real Claude Code sessions. # --------------------------------------------------------------------------- def _apply_env_line(line): line = line.strip() if not line or line.startswith("#") or "=" not in line: return key, _, val = line.partition("=") key = key.strip() val = val.strip().strip('"').strip("'") if " #" in val: val = val[:val.index(" #")].strip() if key and key not in os.environ: os.environ[key] = val def _apply_env_file(env_path): try: with open(env_path, encoding="utf-8") as f: for line in f: _apply_env_line(line) except FileNotFoundError: return def _load_dotenv(): # Test isolation escape hatch: when DASHCLAW_DISABLE_DOTENV is set, skip # the .env walk entirely so the subprocess only sees env vars the test # explicitly passed in. Never set this in production. if os.environ.get("DASHCLAW_DISABLE_DOTENV"): return # Walk up from the hook file's directory looking for env files. Works # whether this runs from hooks/X.py (project root is one parent up) or # from .claude/hooks/X.py after install-hooks runs (project root is two # parents up). Earlier files win because of `key not in os.environ`. tried = set() current = os.path.abspath(os.path.dirname(__file__)) for _ in range(5): for fname in (".env.local", ".env"): env_path = os.path.join(current, fname) if env_path in tried: continue tried.add(env_path) _apply_env_file(env_path) parent = os.path.dirname(current) if parent == current: break current = parent def _resolve_base_url(base_explicit, url_explicit): """Resolve DASHCLAW_BASE_URL/DASHCLAW_URL with explicit-env-beats-dotenv precedence: explicit BASE_URL > explicit URL > dotenv BASE_URL > dotenv URL. Mirrors dashclaw_pretool.py's _resolve_base_url -- see there for the 2026-07-27 incident this fixes.""" base_val = os.environ.get("DASHCLAW_BASE_URL") or "" url_val = os.environ.get("DASHCLAW_URL") or "" if base_explicit and base_val: return base_val if url_explicit and url_val: if base_val and not base_explicit: sys.stderr.write( "[DashClaw] Explicit DASHCLAW_URL=%s overrides a .env-provided " "DASHCLAW_BASE_URL=%s\n" % (url_val, base_val) ) return url_val return base_val or url_val # Captured BEFORE _load_dotenv() fills gaps, so _resolve_base_url can tell an # explicitly-exported value apart from one that only exists because dotenv # populated it. _BASE_URL_EXPLICIT = "DASHCLAW_BASE_ - hooks/enforcement_liveness_probe.pyGitHub
Read the script
#!/usr/bin/env python3 """ DashClaw enforcement-liveness probe (roadmap v8.2). Proves the enforcement seam holds END TO END on a live install: drives a synthetic action that policy must hold (block or approval-wait) through the SAME PreToolUse hook seam real actions use, and verdicts by observing whether the action executed — never by reading the decision ledger, because the ledger is exactly what kept lying in v4.72.1 (a hook timeout misconfig cancelled the pretool hook; guard rows kept flowing while every block failed open). Which seam a run drives is chosen by `--runtime`: each harness declares its hooks in its own config file (Claude Code `.claude/settings.json`, codex `~/.codex/config.toml`), and the probe reads only the one belonging to the runtime it is reporting as. `--settings` overrides that resolution. Harness emulation contract (each clause verified against the real Claude Code harness during the v4.72.1 incident, 2026-07-06; the timer-overflow clause is NOT assumed to hold for other harnesses — see OVERFLOW_HARNESSES): 1. The hook `timeout` field in settings.json is SECONDS (default 600). 2. The harness arms a timer of timeout*1000 ms; a value whose ms product exceeds 2^31-1 overflows the timer, which fires immediately and cancels the hook (73/73 cancellations observed). 3. Exit code 2 blocks the tool; any other outcome — exit 0, other codes, cancellation — lets the tool PROCEED (fail-open). The probe reproduces exactly that contract around the real, unmodified hook: exit 2 => the synthetic action is not executed; anything else => the probe executes it (a Write of a marker file) and the file's existence is the witness. Verdicts: held — the seam blocked the probe action (exit 2), witness absent. executed — the harness contract would have let the action through and the witness file exists (v4.72.1 class: overflowed timeout, hook crash, fail-open path). unprovable — the seam ran but enforcement cannot be proven: no hook entry installed, observe mode, no policy holds the probe action, or the hook hung past the probe's wait budget. Rendered broken on the surfaces — you cannot claim enforcement you cannot prove. Synthetic hygiene: the probe runs the hook as agent `smoke-liveness-probe` (the `smoke-` prefix is the established synthetic marker), so its guard rows are excluded from every aggregate, and any pending approval it creates is cancelled in teardown. Its verdict is filed to POST /api/enforcement-liveness — its own table, never the action/guard ledgers (live-canary precedent). SessionStart entry point: wired directly as the SessionStart hook (it replaced the retired session-digest hook that used to spawn it). Invoked as `--source session-start`, the probe throttles itself to at most once per 12h (marker file) and runs the actual probe in a DETACHED child, so session start is never delayed or broken — the same contract the digest provided. Every other `--source` (manual, ci, session-start's own detached child) runs the probe inline. Usage: python hooks/enforcement_liveness_probe.py [--settings PATH] [--source S] [--witness-dir DIR] [--max-wait SECONDS] [--json] Env: DASHCLAW_BASE_URL + DASHCLAW_API_KEY enable reporting (loaded from .env like the hooks). DASHCLAW_LIVENESS_PROBE_DISABLED=1 exits 0 immediately. Exit code: 0 when the verdict is `held` (and the report, if configured, filed); 1 otherwise. """ import argparse import json import os import shutil import subprocess import sys import time import urllib.error import urllib.request import uuid from datetime import datetime, timezone # Codex declares its hooks in TOML, Claude Code in JSON. tomllib is stdlib from # 3.11; older interpreters need the tomli backport. A missing parser is # reported as a config problem rather than swallowed — silently finding no # codex seam would render the same false green this probe exists to catch. try: import tomllib except ImportError: # pragma: no cover - only on Python <= 3.10 try: import tomli as tomllib except ImportError: tomllib = None # Same .env loading contract as the hooks (values already in the environment # win; DASHCLAW_DISABLE_DOTENV skips the walk for test isolation). def _apply_env_line(line): line = line.strip() if not line or line.startswith("#") or "=" not in line: return key, _, val = line.partition("=") key = key.strip() val = val.strip().strip('"').strip("'") if " #" in val: val = val[:val.index(" #")].strip() if key and key not in os.environ: os.environ[key] = val def _load_dotenv(): if os.environ.get("DASHCLAW_DISABLE_DOTENV"): return tried = set() current = os.path.abspath(os.path.dirname(__file__)) for _ in range(5): for fname in (".env.local", ".env"): env_path = os.path.join(current, fname) if env_path in tried: continue tried.add(env_path) try: with open(env_path, encoding="utf-8") as f: for line in f: _apply_env_line(line) except FileNotFoundError: pass parent = os.path.dirname(current) if parent == current: break current = parent _load_dotenv() INT32_MAX_MS = 2**31 - 1 DEFAULT_HOOK_TIMEOUT_SECONDS = 600 # harness default when the field is absent PROBE_AGENT_ID = "smoke-liveness-probe" # `smoke-` = synthetic, excluded everywhere APPROVAL_WAIT_SECONDS = "8" # bounds the hook's approval poll for the probe run # How each harness declares its PreToolUse-equivalent hook, and how it says # "held". Everything that differs BETWEEN seams lives here so the probe body # stays one implementation rather than a per-runtime variant: # # suffix the config file's extension, which is also how a path maps back # to its harness (.jso - hooks/run_hook.cjsGitHub
Read the script
#!/usr/bin/env node // DashClaw hook launcher. // // Resolves a working Python interpreter (python3 first, then python) and runs // the named hook script from this directory with stdin/stdout/stderr passed // through and the exit code mirrored. // // Why this exists: the plugin's hooks.json is static, so it cannot know // whether the host has `python` (Windows) or only `python3` (macOS, most // Linux). A `python3 X || python X` one-liner is NOT safe — a guard block // exits 2, which `||` treats as failure and re-runs the hook (double guard // call, double action record). Node is always present (Claude Code runs on // it), so this shim probes once per invocation and runs the script exactly // once. 'use strict'; const { spawnSync } = require('node:child_process'); const path = require('node:path'); function resolvePython() { for (const cmd of ['python3', 'python']) { // The Windows Store `python3` alias exits non-zero without running // anything, so a successful --version is required, not just spawnability. const probe = spawnSync(cmd, ['--version'], { stdio: 'ignore' }); if (!probe.error && probe.status === 0) return cmd; } return null; } const script = process.argv[2]; if (!script || script.includes('/') || script.includes('\\') || script.includes('..')) { console.error('[DashClaw] run_hook.cjs: expected a hook script filename argument'); process.exit(0); // never break the user's tool call over launcher misuse } const python = resolvePython(); if (!python) { console.error('[DashClaw] No python3 or python found on PATH — governance hook skipped for this call.'); process.exit(0); // proceed-with-notice: do not hard-block tool calls on a missing interpreter } const result = spawnSync(python, [path.join(__dirname, script), ...process.argv.slice(3)], { stdio: 'inherit', }); process.exit(result.status === null ? 0 : result.status);
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withdashclaw
🛡️ The approval and policy layer for AI agents. Intercept risky actions before they run, block them, or approve them remotely.
Get the whole plugin
Stats
294
Stars
49
Forks
Active
Maintenance
TypeScript
Language
MIT
License
1d ago
Last commit
6mo ago
Created
Repo: ucsandman/DashClaw

