{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/secret-block",
  "version": "1.0.0",
  "name": "Secret Block",
  "description": "PreToolUse hook that denies Bash commands containing secret-shaped strings before they execute. Reads the canonical pattern set from hooks/patterns.json (plus patterns.local.json if present). Belt-and-suspenders alongside the secret-handling rules in Emits a JSON permissionDecision deny on detection.",
  "event": "PreToolUse",
  "language": "python",
  "trigger": {
    "type": "tool-name",
    "pattern": "Bash"
  },
  "blocking": true,
  "side_effects": [
    "denies tool call with explanation when secret pattern detected"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "security",
    "secrets",
    "governance",
    "patterns",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_notes": "Logic is cross-platform. Wiring: use 'ai hooks run secret-block' in settings.json — the ai binary discovers Python on each OS. Pure Python regex on stdin JSON. Works on all platforms.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/secret-block.py — PreToolUse hook that denies Bash commands\ncontaining secret-shaped strings before they execute.\n\nReads the canonical pattern set from hooks/patterns.json\n(+ patterns.local.json if present). Belt-and-suspenders alongside the\nsecret-handling rules in Common.md §4. Per SPEC.md §10.1.\n\nInput contract (Claude Code PreToolUse):\n  - The full tool-use payload arrives on stdin as JSON.\n  - On detection: emit JSON permissionDecision deny on stdout, exit 0.\n  - On clean: exit 0, no stdout.\n  - Stderr is shown to the user.\n\nOutput schema:\n  {\n    \"hookSpecificOutput\": {\n      \"hookEventName\": \"PreToolUse\",\n      \"permissionDecision\": \"deny\",\n      \"permissionDecisionReason\": \"<explanation>\"\n    }\n  }\n\nSelf-check:\n  --self-check  Loads patterns.json and compiles every regex.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport sys\nfrom pathlib import Path\n\n# Allow `import _lib` when the hooks dir is on PYTHONPATH OR when this\n# script is exec'd directly from ~/.ai/hooks/.\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nimport _lib  # noqa: E402\n\n\ndef deny(reason: str) -> None:\n    \"\"\"Emit a permission-deny decision via JSON stdout and exit 0.\"\"\"\n    print(json.dumps({\n        \"hookSpecificOutput\": {\n            \"hookEventName\": \"PreToolUse\",\n            \"permissionDecision\": \"deny\",\n            \"permissionDecisionReason\": reason,\n        }\n    }))\n    sys.exit(0)\n\n\ndef extract_command(payload: dict) -> str:\n    \"\"\"Best-effort extraction of the command being run from the\n    Claude tool-use payload shape. Falls back to stringifying the\n    whole payload, which still matches patterns.\"\"\"\n    if not isinstance(payload, dict):\n        return json.dumps(payload)\n    # Common shapes across Claude Code event versions.\n    cmd = (\n        payload.get(\"command\")\n        or payload.get(\"input\", {}).get(\"command\")\n        or payload.get(\"params\", {}).get(\"command\")\n        or payload.get(\"tool_input\", {}).get(\"command\")\n    )\n    if cmd:\n        return cmd\n    return json.dumps(payload)\n\n\ndef main(argv: list) -> int:\n    if \"--self-check\" in argv:\n        return _lib.self_check_ok()\n\n    raw = sys.stdin.read()\n    if not raw.strip():\n        # No payload to inspect; allow by default.\n        return 0\n\n    try:\n        payload = json.loads(raw)\n    except json.JSONDecodeError:\n        # If we can't parse, still scan the raw text for patterns.\n        payload = {\"raw\": raw}\n\n    # Only guard PreToolUse events on Bash/shell tools.\n    if isinstance(payload, dict):\n        hook_event = (\n            payload.get(\"hookEventName\")\n            or payload.get(\"hook_event_name\")\n            or \"\"\n        )\n        if hook_event and hook_event != \"PreToolUse\":\n            return 0\n\n        tool_name = payload.get(\"tool_name\") or payload.get(\"toolName\") or \"\"\n        if tool_name and tool_name not in (\"Bash\", \"shell\", \"execute\"):\n            return 0\n\n        # Extract the command to scan.\n        tool_input = (\n            payload.get(\"tool_input\")\n            or payload.get(\"toolInput\")\n            or payload.get(\"toolArgs\")\n            or {}\n        )\n        if isinstance(tool_input, dict):\n            command = tool_input.get(\"command\") or tool_input.get(\"cmd\") or \"\"\n        else:\n            command = extract_command(payload)\n    else:\n        command = raw\n\n    if not command:\n        return 0\n\n    patterns = _lib.load_patterns()\n    hits = _lib.scan_lines(command.splitlines() or [command], patterns)\n    if not hits:\n        return 0\n\n    # Build a deny reason that does NOT echo the full secret value.\n    # Take the first hit and construct a truncated/redacted snippet.\n    hit = hits[0]\n    pattern_id = hit.get(\"pattern_id\", \"unknown\")\n    severity = hit.get(\"severity\", \"medium\")\n    snippet = hit.get(\"snippet\", \"[redacted]\")\n\n    extra = \"\"\n    if len(hits) > 1:\n        extra = f\" (and {len(hits) - 1} more match(es))\"\n\n    deny(\n        f\"Possible secret detected in Bash command: pattern={pattern_id} severity={severity}{extra}.\\n\"\n        f\"Snippet (redacted): {snippet}\\n\"\n        \"Per Common.md §1.P4 (no secrets in artifacts; non-overridable).\\n\"\n        \"Use OS clipboard transfer (Common.md §4.2) instead of embedding secrets in commands.\"\n    )\n    # deny() calls sys.exit(0); this return is unreachable but satisfies type checkers.\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main(sys.argv[1:]))\n",
  "depends_on": [
    "hook/lib"
  ],
  "category": "security"
}