Skip to content
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.

From plugin
dashclaw
29120 skills3 agents4 commands5 hooks
+1
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