{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/op-redact",
  "version": "1.0.0",
  "name": "1Password & Secret Redactor",
  "description": "PreToolUse hook that redacts 1Password references and secret-shaped values from Claude Code tool-use payloads before they execute. Unlike secret-block which denies, this hook redacts in-place and always exits 0 — it never blocks the tool call. Patterns: GitHub tokens (gho_/ghp_/ghu_/ghs_/ghr_), Bearer tokens, op:// references, OpenAI sk- keys, PEM blocks. Writes a violation record on detection.",
  "event": "PreToolUse",
  "language": "python",
  "trigger": {
    "type": "always"
  },
  "blocking": false,
  "side_effects": [
    "redacts secrets in-place in tool payload",
    "writes violation record to ~/.ai/audit/violations/<UTC>-secret-detected.md",
    "outputs cleaned JSON to stdout"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "security",
    "secrets",
    "redaction",
    "1password",
    "governance",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_notes": "Logic is cross-platform. Wiring: use 'ai hooks run op-redact' in settings.json — the ai binary discovers Python on each OS. Pure Python regex; no OS-specific calls. Works on all platforms.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/op-redact.py — PreToolUse hook that redacts 1Password and other\nsecret-shaped values from Claude Code tool-use payloads.\n\nUnlike secret-block.py (which DENIES the tool call), this hook:\n  - Redacts matching strings in-place across all string fields.\n  - Writes a violation record to $AI_ROOT/audit/violations/<UTC>-secret-detected.md.\n  - Outputs the cleaned JSON to stdout.\n  - ALWAYS exits 0 — it never blocks the tool call.\n\nPer Common.md §4 (non-overridable: secrets must not appear in artifacts)\nand SPEC.md §10.1.\n\nRedaction patterns (inline — no dependency on patterns.json):\n  gho_ / ghp_ / ghu_ / ghs_ / ghr_  + 36+ chars → [REDACTED:github-token]\n  github_pat_ + 60+ chars             → [REDACTED:github-token]\n  Bearer  + 20+ chars                 → [REDACTED:bearer-token]\n  op://                               → [REDACTED:op-ref]\n  sk- + 40+ chars                     → [REDACTED:openai-key]\n  -----BEGIN                          → [REDACTED:pem-block]\n\nInput contract (Claude Code PreToolUse event):\n  - Full tool-use payload arrives on stdin as JSON.\n  - Exit 0 always (redact + log, never block).\n  - stdout: cleaned JSON.\n  - stderr: human-readable diagnostic lines.\n\nSelf-check:\n  --self-check  Verifies regex compilation; exits 0 on success.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport re\nimport sys\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\n# ---------------------------------------------------------------------------\n# Inline redaction patterns — no dependency on patterns.json.\n# Each tuple: (compiled_regex, replacement_string, kind_label)\n# ---------------------------------------------------------------------------\n\n_RAW_PATTERNS: list[tuple[str, str, str]] = [\n    # GitHub classic tokens\n    (r\"(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\", \"[REDACTED:github-token]\", \"github-token\"),\n    # GitHub fine-grained PAT\n    (r\"github_pat_[A-Za-z0-9_]{60,}\", \"[REDACTED:github-token]\", \"github-token\"),\n    # Bearer tokens (20+ chars after 'Bearer ')\n    (r\"Bearer [A-Za-z0-9._\\-]{20,}\", \"[REDACTED:bearer-token]\", \"bearer-token\"),\n    # op:// references — redact the entire op:// URI\n    (r\"op://[^\\s\\\"']+\", \"[REDACTED:op-ref]\", \"op-ref\"),\n    # OpenAI-style keys: sk- followed by 40+ chars (excludes sk-ant- Anthropic keys\n    # which have their own label, but this is belt-and-suspenders)\n    (r\"sk-[A-Za-z0-9_\\-]{40,}\", \"[REDACTED:openai-key]\", \"openai-key\"),\n    # PEM block headers\n    (r\"-----BEGIN[^\\n\\r]*\", \"[REDACTED:pem-block]\", \"pem-block\"),\n]\n\n_PATTERNS: list[tuple[re.Pattern, str, str]] = [\n    (re.compile(raw), repl, kind)\n    for raw, repl, kind in _RAW_PATTERNS\n]\n\n\ndef redact_string(value: str) -> tuple[str, list[str]]:\n    \"\"\"Apply all patterns to value. Returns (redacted_value, list_of_kind_hits).\"\"\"\n    hits: list[str] = []\n    out = value\n    for pattern, replacement, kind in _PATTERNS:\n        new, n = pattern.subn(replacement, out)\n        if n > 0:\n            hits.append(kind)\n            out = new\n    return out, hits\n\n\ndef redact_recursive(obj: object) -> tuple[object, list[str]]:\n    \"\"\"Walk obj depth-first and redact all string leaves.\n    Returns (cleaned_obj, all_hit_kinds).\"\"\"\n    all_hits: list[str] = []\n    if isinstance(obj, str):\n        cleaned, hits = redact_string(obj)\n        return cleaned, hits\n    if isinstance(obj, dict):\n        out = {}\n        for k, v in obj.items():\n            cleaned_v, hits = redact_recursive(v)\n            out[k] = cleaned_v\n            all_hits.extend(hits)\n        return out, all_hits\n    if isinstance(obj, list):\n        out_list = []\n        for item in obj:\n            cleaned_item, hits = redact_recursive(item)\n            out_list.append(cleaned_item)\n            all_hits.extend(hits)\n        return out_list, all_hits\n    # int, float, bool, None — pass through\n    return obj, []\n\n\ndef ai_root() -> Path:\n    \"\"\"Return the canonical ~/.ai/ root, honoring $AI_ROOT.\"\"\"\n    env = os.environ.get(\"AI_ROOT\", \"\")\n    if env:\n        return Path(env)\n    return Path.home() / \".ai\"\n\n\ndef write_violation(kinds: list[str], payload_summary: str) -> None:\n    \"\"\"Write a violation record to $AI_ROOT/audit/violations/<UTC>-secret-detected.md.\"\"\"\n    now = datetime.now(tz=timezone.utc)\n    ts = now.strftime(\"%Y-%m-%dT%H%M%SZ\")\n    violations_dir = ai_root() / \"audit\" / \"violations\"\n    try:\n        violations_dir.mkdir(parents=True, exist_ok=True)\n        path = violations_dir / f\"{ts}-secret-detected.md\"\n        unique_kinds = sorted(set(kinds))\n        body = f\"\"\"# Violation — {ts}\n\n- **File / Rule violated:** Common.md/§4 — No Secrets In Artifacts\n- **What happened:** op-redact.py PreToolUse hook detected {len(kinds)} secret-like match(es) in a Claude Code tool-use payload and redacted them before the payload was processed. Pattern kinds: {', '.join(unique_kinds)}.\n- **How noticed:** tool-flagged (op-redact.py)\n- **Remediation:** Values were replaced with [REDACTED:<kind>] in the cleaned payload written to stdout. The original payload was not passed downstream.\n- **Payload summary (redacted):** {payload_summary[:200]}\n\"\"\"\n        path.write_text(body, encoding=\"utf-8\")\n    except OSError as exc:\n        # Logging failures must not cause the hook to block.\n        print(f\"[ai/op-redact] WARNING: could not write violation file: {exc}\", file=sys.stderr)\n\n\ndef hook_name() -> str:\n    return \"op-redact\"\n\n\ndef log(*parts) -> None:\n    print(f\"[ai/{hook_name()}]\", *parts, file=sys.stderr, flush=True)\n\n\ndef self_check_ok() -> int:\n    \"\"\"Compile all patterns; exit 0 if OK.\"\"\"\n    try:\n        for raw, _, _ in _RAW_PATTERNS:\n            re.compile(raw)\n    except re.error as exc:\n        log(f\"self-check FAIL: regex compile error: {exc}\")\n        return 1\n    log(\"self-check OK\")\n    return 0\n\n\ndef main(argv: list[str]) -> int:\n    if \"--self-check\" in argv:\n        return self_check_ok()\n\n    raw = sys.stdin.read()\n    if not raw.strip():\n        # Empty payload — nothing to redact; pass through empty.\n        sys.stdout.write(\"\")\n        return 0\n\n    # Parse payload. Fall back to wrapping raw in a dict so we can still\n    # scan it for patterns.\n    try:\n        payload = json.loads(raw)\n        parse_ok = True\n    except json.JSONDecodeError:\n        payload = {\"_raw\": raw}\n        parse_ok = False\n\n    cleaned, hits = redact_recursive(payload)\n\n    if hits:\n        log(f\"{len(hits)} secret-like match(es) redacted: {sorted(set(hits))}\")\n        log(\"Per Common.md §1.P4 (no secrets in artifacts; non-overridable).\")\n        # Summarize the cleaned output (already redacted) for the violation record.\n        summary = json.dumps(cleaned)[:200]\n        write_violation(hits, summary)\n\n    # Always output the cleaned payload as JSON so the tool call proceeds.\n    if parse_ok:\n        sys.stdout.write(json.dumps(cleaned))\n    else:\n        # We were given unparseable JSON; output what we managed to clean.\n        sys.stdout.write(cleaned.get(\"_raw\", raw) if isinstance(cleaned, dict) else raw)\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main(sys.argv[1:]))\n",
  "category": "security"
}