{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/lib",
  "version": "1.0.0",
  "name": "Hook Library",
  "description": "Shared Python library required by most governance hooks. Provides redaction helpers, JSON I/O, audit logging, and pattern matching utilities.",
  "event": "",
  "language": "python",
  "trigger": {
    "type": "library"
  },
  "blocking": false,
  "lifecycle": "stable",
  "authored_by": "convergent-systems-key",
  "tags": [
    "lib",
    "shared",
    "governance"
  ],
  "script": "\"\"\"Shared helpers for the hooks at this directory.\n\nEvery hook is expected to:\n\n  - Be runnable on stdlib python3 only (no third-party deps).\n  - Honor --self-check, returning exit 0 on the canonical self-test\n    and exit 1 otherwise. The self-check verifies the hook can load\n    its dependencies, finds its config files, and compiles its\n    regexes. It does NOT exercise the hook's main behavior.\n  - Print structured `[ai/<hook-name>]` lines to stderr, never to\n    stdout (stdout is reserved for downstream tools).\n  - Redact secrets before logging — `_lib.redact()` does this.\n\nPer SPEC.md §3.10 and §10.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport re\nimport sys\nfrom pathlib import Path\nfrom typing import Iterable\n\nHOOKS_DIR = Path(__file__).resolve().parent\n\n\ndef patterns_path() -> Path:\n    \"\"\"Return the path to hooks/patterns.json, honoring ${AI_ROOT}/hooks/\n    when invoked from a wrapper outside the repo tree.\"\"\"\n    candidates = [\n        HOOKS_DIR / \"patterns.json\",\n        Path(os.environ.get(\"AI_ROOT\", str(Path.home() / \".ai\"))) / \"hooks\" / \"patterns.json\",\n    ]\n    for p in candidates:\n        if p.is_file():\n            return p\n    return HOOKS_DIR / \"patterns.json\"\n\n\ndef patterns_local_path() -> Path:\n    \"\"\"Return the optional local-patterns path; may not exist.\"\"\"\n    return patterns_path().with_name(\"patterns.local.json\")\n\n\ndef load_patterns() -> list[dict]:\n    \"\"\"Load patterns.json plus optional patterns.local.json. Returns a\n    list of pattern dicts (the union of both). Local entries override\n    canonical entries with the same id.\"\"\"\n    base: list[dict] = []\n    p = patterns_path()\n    if p.is_file():\n        data = json.loads(p.read_text(encoding=\"utf-8\"))\n        base = data.get(\"patterns\", [])\n\n    local: list[dict] = []\n    lp = patterns_local_path()\n    if lp.is_file():\n        data = json.loads(lp.read_text(encoding=\"utf-8\"))\n        local = data.get(\"patterns\", [])\n\n    # local overrides by id\n    by_id = {p[\"id\"]: p for p in base}\n    for entry in local:\n        by_id[entry[\"id\"]] = entry\n    out = list(by_id.values())\n    # compile regexes once for the lifetime of the process\n    for entry in out:\n        entry[\"_compiled\"] = re.compile(entry[\"regex\"])\n    return out\n\n\ndef scan_lines(lines: Iterable[str], patterns: list[dict]) -> list[dict]:\n    \"\"\"Walk every line through every pattern; return a list of hits.\"\"\"\n    hits = []\n    for lineno, line in enumerate(lines, start=1):\n        for entry in patterns:\n            for m in entry[\"_compiled\"].finditer(line):\n                hits.append({\n                    \"pattern_id\": entry[\"id\"],\n                    \"severity\": entry.get(\"severity\", \"medium\"),\n                    \"redaction\": entry.get(\"redaction\", \"[REDACTED]\"),\n                    \"line\": lineno,\n                    \"col\": m.start() + 1,\n                    \"snippet\": redact_snippet(line, m.start(), m.end(), entry.get(\"redaction\", \"[REDACTED]\")),\n                })\n    return hits\n\n\ndef redact(input_str: str, patterns: list[dict] | None = None) -> str:\n    \"\"\"Apply every pattern's redaction to the input.\"\"\"\n    if patterns is None:\n        patterns = load_patterns()\n    out = input_str\n    for entry in patterns:\n        out = entry[\"_compiled\"].sub(entry.get(\"redaction\", \"[REDACTED]\"), out)\n    return out\n\n\ndef redact_snippet(line: str, start: int, end: int, redaction: str, ctx: int = 20) -> str:\n    a = max(0, start - ctx)\n    b = min(len(line), end + ctx)\n    return line[a:start] + redaction + line[end:b]\n\n\ndef hook_name() -> str:\n    \"\"\"Best-effort hook name derived from argv[0]. Used in log prefixes.\"\"\"\n    base = os.path.basename(sys.argv[0]) if sys.argv else \"hook\"\n    if base.endswith(\".py\"):\n        base = base[:-3]\n    return base\n\n\ndef log(*parts) -> None:\n    \"\"\"Structured stderr log line.\"\"\"\n    print(f\"[ai/{hook_name()}]\", *parts, file=sys.stderr, flush=True)\n\n\ndef self_check_ok() -> int:\n    \"\"\"The default --self-check pass. Hooks override or extend this.\"\"\"\n    try:\n        load_patterns()\n    except Exception as e:\n        log(\"self-check FAIL:\", e)\n        return 1\n    log(\"self-check OK\")\n    return 0\n",
  "category": "governance"
}