Ctrl K
ComunidadGuías…

claude_hook.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Claude Code hook: sends EVERY Claude Code tool call (Bash, Edit, Write, Read, WebFetch, MCP tools...)
through Squidbrake before it runs, and reports the result afterwards.

  allowed by the rules    -> Claude Code carries on as normal (its own permission prompts still apply)
  blocked by a rule       -> the call is denied and Claude is told why
  held for approval       -> Claude Code waits (up to ~9 minutes) until someone approves / rejects it
                             in the dashboard

It also records what you ask (UserPromptSubmit), so the gateway can tell addresses you gave from ones a web page gave.

Install it with:  python connect.py claude-code   (or the Claude Code plugin: see plugin/README.md)
Settings (env vars, set by connect.py in the hook command):
  GATEWAY_URL, GATEWAY_API_KEY, GATEWAY_SOURCE (default "claude-code"),
  GATEWAY_FAIL_OPEN=1 to let calls run when the gateway is unreachable (default: what rules.yaml's
  when_unreachable says for that call, see offline.py; with no copy of it yet, block)

It also reports the model tokens Claude Code used (read from the conversation's transcript file), so rules.yaml
`budgets:` can pause an agent that's over its daily or monthly limit.
"""
from __future__ import annotations

import json
import os
import sys
import tempfile
import time
from pathlib import Path

import httpx

try:  # what a command will change, and an undo for what it destroys (both best effort, never fatal)
    import commands
    import effects
    import runs
    import undo
except Exception:  # pragma: no cover
    commands = effects = runs = undo = None
try:
    import hooklog   # which agent ran the hook and when, for `squidbrake doctor`
except Exception:  # pragma: no cover
    hooklog = None
try:
    import offline   # what to do while the gateway is down (rules.yaml when_unreachable)
except Exception:  # pragma: no cover
    offline = None

def _arg(flag: str, env: str, default: str) -> str:
    # Claude Code hook config has no env field, so connect.py passes settings as arguments.
    # Installed as a Claude Code plugin, they come from the plugin's settings instead.
    if flag in sys.argv[1:-1]:
        return sys.argv[sys.argv.index(flag) + 1]
    plugin_option = {"GATEWAY_URL": "CLAUDE_PLUGIN_OPTION_GATEWAY_URL", "GATEWAY_API_KEY": "CLAUDE_PLUGIN_OPTION_API_KEY"}.get(env)
    return os.getenv(env) or (plugin_option and os.getenv(plugin_option)) or default


GATEWAY_URL = _arg("--url", "GATEWAY_URL", "http://localhost:8080").rstrip("/")
GATEWAY_API_KEY = _arg("--key", "GATEWAY_API_KEY", "") or (offline.load_key(GATEWAY_URL) if offline else "")
SOURCE = _arg("--source", "GATEWAY_SOURCE", "claude-code")
FAIL_OPEN = os.getenv("GATEWAY_FAIL_OPEN", "").lower() in ("1", "true", "yes")
MAX_WAIT = float(os.getenv("GATEWAY_MAX_WAIT", "540"))  # stay under Claude Code's 600 s hook timeout
# Tools that already report to the gateway themselves (gateway_mcp.py) - don't record them twice.
SKIP_PREFIXES = ("mcp__gateway-db__", "mcp__gw-")
STATE_DIR = Path(tempfile.gettempdir()) / "squidbrake-hook"


def deny(reason: str, stop: bool = False) -> None:
    """Refuse this tool call. stop=True also ends Claude's turn (the agent or its session was stopped)."""
    out: dict = {"hookSpecificOutput": {
        "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}
    if stop:
        out.update({"continue": False, "stopReason": reason})
    print(json.dumps(out))
    sys.exit(0)


def gateway_client(url: str, key: str, timeout: float) -> httpx.Client:
    """The gateway on this computer is reached directly: 127.0.0.1, not localhost (Windows tries IPv6 ::1 first, and
    waits about 2 s for each call to fail over), and never through a proxy (on Windows the system proxy setting is
    read without its "bypass for local addresses" list, so a company proxy would get every hook call)."""
    host = httpx.URL(url).host
    if host == "localhost":
        url = url.replace("//localhost", "//127.0.0.1", 1)
    return httpx.Client(base_url=url, headers={"X-Gateway-Key": key}, timeout=timeout,
                        trust_env=host not in ("localhost", "127.0.0.1", "::1"))


def unreachable(url: str, e: httpx.HTTPError, what: str = "action") -> str:
    """Why everything is blocked, in words a person can act on (the agent passes it on). Same text in agent_hook.py."""
    status = getattr(getattr(e, "response", None), "status_code", None)
    if isinstance(e, (ValueError, KeyError, TypeError)):
        why = f"Something at {url} answered, but not like Squidbrake does (a proxy or another app on that port?)"
    elif "rejected" in str(e):
        why = f"Squidbrake's dashboard at {url} rejected this computer's key (it may have been removed)"
    elif status in (502, 503, 504) or isinstance(e, (httpx.ConnectError, httpx.TimeoutException)):
        why = f"Squidbrake's dashboard at {url} isn't answering: it may be switched off, restarting, or deleted"
    else:
        why = f"Squidbrake at {url} is unreachable or misconfigured ({e})"
    return (f"{why}. Every {what} must go through it, so this was blocked. Tell the user: start it again (or ask "
            f"whoever runs it). If it was removed on purpose, take Squidbrake out of this computer's agents with: "
            f"squidbrake connect all --remove")


PAUSED = ("emergency-stop", "session-stop")
EDITS = ("Write", "Edit", "MultiEdit", "NotebookEdit")


def paused(d: dict) -> bool:
    """Stopped, or over a spend limit: the agent should stop working, not just skip this call."""
    return d.get("rule_id") in PAUSED or str(d.get("rule_id") or "").startswith("budget:") \
        or (d.get("decision_note") or "").startswith("The session was stopped")


def transcript_usage(ev: dict) -> tuple[dict | None, dict | None]:
    """Tokens Claude Code used since the last report, from its transcript (one JSON object per line; each model reply
    carries `usage`, repeated on every line of that reply, so replies are counted once by id).
    -> (usage to send, the reading position to save once the gateway has it)."""
    path, session = ev.get("transcript_path"), str(ev.get("session_id") or "")
    if not path or not session:
        return None, None
    safe = "".join(c for c in session if c.isalnum() or c in "-_")[:80]
    state_file = STATE_DIR / f"usage-{safe}.json"
    try:
        state = json.loads(state_file.read_text())
    except (OSError, ValueError):
        state = {"offset": 0, "seen": []}
    seen = set(state.get("seen") or [])
    total = {"input_tokens": 0, "output_tokens": 0, "model": None}
    try:
        with open(path, "rb") as f:
            f.seek(int(state.get("offset") or 0))
            chunk = f.read(20_000_000)
    except (OSError, ValueError):
        return None, None
    end = chunk.rfind(b"\n") + 1                       # only whole lines; a half-written one is read next time
    for line in chunk[:end].splitlines():
        try:
            msg = json.loads(line).get("message")
        except (ValueError, AttributeError):
            continue
        if not isinstance(msg, dict):
            continue
        u, mid = msg.get("usage"), msg.get("id")
        if not isinstance(u, dict) or not mid or mid in seen:
            continue
        seen.add(mid)
        total["input_tokens"] += int(u.get("input_tokens") or 0) + int(u.get("cache_creation_input_tokens") or 0)
        total["output_tokens"] += int(u.get("output_tokens") or 0)
        total["model"] = msg.get("model") or total["model"]
    new_state = {"file": state_file, "offset": int(state.get("offset") or 0) + end, "seen": list(seen)[-500:]}
    return (total if total["input_tokens"] or total["output_tokens"] else None), new_state


def save_usage_state(new_state: dict | None) -> None:
    if new_state:
        try:
            STATE_DIR.mkdir(exist_ok=True)
            new_state["file"].write_text(json.dumps({"offset": new_state["offset"], "seen": new_state["seen"]}))
        except OSError:
            pass


def notes_text(d: dict) -> str:
    """Notes people left for this agent (Decision.notes), as Claude should read them. Same words as gw_async.py."""
    return "".join(f"NOTE FROM {n.get('by')} (a person on your team, sent through Squidbrake): {n.get('text')}\n\n"
                   for n in d.get("notes") or [])


def keep_copy(ev: dict, line: str | None, d: dict, http: httpx.Client) -> str | None:
    """Undo: back up what this call destroys or overwrites, before it runs. When the rules require a copy and none
    could be made, the call doesn't run (and the gateway is told why). -> what to tell the user about the copy."""
    want, strict = d.get("snapshot"), bool(d.get("snapshot_strict"))
    kept = None
    if line and undo is not None:
        kept = undo.snapshot(line, ev.get("cwd"))
    elif ev.get("tool_name") in EDITS and undo is not None:
        inp = ev.get("tool_input") or {}
        kept = undo.snapshot_files([str(inp.get("file_path") or inp.get("notebook_path") or "")], ev.get("cwd"),
                                   str(ev.get("tool_name")).lower())
    elif want and strict:
        kept = {"skipped": f"Squidbrake can't keep a copy of what {ev.get('tool_name')} changes from here"}
    if want and kept is not None:
        body = {"skipped": kept["skipped"]} if kept.get("skipped") else \
            {"kind": "local", "ref": kept["id"], "summary": undo.describe(kept)}
        try:
            http.post(f"/v1/events/{d['event_id']}/snapshot", json=body)
        except httpx.HTTPError:
            pass
    if kept and kept.get("skipped") and want == "required":
        try:
            http.post(f"/v1/events/{d['event_id']}/result", json={"error": f"not run: no undo copy ({kept['skipped']})"})
        except httpx.HTTPError:
            pass
        deny(f"Squidbrake keeps a copy before changes like this one and couldn't ({kept['skipped']}), so it didn't "
             "run. Tell the user; they can make a backup themselves or set snapshot: best_effort on the rule.")
    if kept and not kept.get("skipped"):
        return undo.describe(kept)
    return None


def shell_command(ev: dict) -> str | None:
    if commands is None or ev.get("tool_name") not in ("Bash", "PowerShell"):
        return None
    return commands.command_of(ev.get("tool_input"))


def pre(ev: dict, http: httpx.Client) -> None:
    line = shell_command(ev)
    body = {
        "name": ev.get("tool_name", "unknown"), "kind": "claude_code", "input": ev.get("tool_input"),
        "source": SOURCE, "session_id": ev.get("session_id"),
        "metadata": {"cwd": ev.get("cwd"), "permission_mode": ev.get("permission_mode"),
                     "tool_use_id": ev.get("tool_use_id"), "accepts_notes": True},
    }
    spent, usage_state = transcript_usage(ev)
    if spent:
        body["metadata"]["usage"] = spent
    if line:
        try:
            if found := effects.predict(line, ev.get("cwd")):
                body["metadata"]["effects"] = found
        except Exception:
            pass
        try:  # what `make clean` / `npm run x` / `bash x.sh` runs underneath, so the gateway checks that too
            if found := runs.expand(line, ev.get("cwd")):
                body["metadata"]["runs"] = found
        except Exception:
            pass
    try:
        r = http.post("/v1/events", json=body)
        if r.status_code == 401:
            raise httpx.HTTPError("the gateway rejected GATEWAY_API_KEY")
        r.raise_for_status()
        d = r.json()
        save_usage_state(usage_state)
        if offline is not None:
            offline.refresh(http, GATEWAY_URL)
        if d["decision"] == "review":
            undo_note = " It can't be undone once it runs." if d.get("cannot_undo") else ""
            print(f"Squidbrake: '{body['name']}' is waiting for approval at {GATEWAY_URL}/dashboard.{undo_note}",
                  file=sys.stderr, flush=True)
            end = time.monotonic() + MAX_WAIT
            while d["decision"] == "review" and time.monotonic() < end:
                wait = min(25.0, end - time.monotonic())
                r = http.get(f"/v1/events/{d['event_id']}/decision", params={"wait": wait}, timeout=wait + 10)
                r.raise_for_status()
                d = r.json()
    except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:   # not JSON / not our answer: blocked too
        if offline is not None and isinstance(e, httpx.HTTPError) and "rejected" not in str(e):
            run, why = offline.decide(GATEWAY_URL, name=body["name"], kind=body["kind"], source=SOURCE,
                                      session_id=body["session_id"], input=body["input"], fail_open=FAIL_OPEN)
            if run:
                print(json.dumps({"systemMessage": f"Squidbrake is unreachable; {why}."}))
                return
            deny(unreachable(GATEWAY_URL, e, "tool call") + f" ({why[:1].upper() + why[1:]}.)")
        if FAIL_OPEN:
            return
        deny(unreachable(GATEWAY_URL, e, "tool call"))

    told = notes_text(d)        # a person's note rides on this answer, allowed or not
    if d["decision"] == "review":
        deny("Squidbrake: nobody approved this in time. Ask the user to approve it in the dashboard, then try again.")
    if d["decision"] == "deny":
        by = d.get("decided_by")
        if paused(d):
            deny(f"{told}Squidbrake: {d.get('decision_note') or d.get('reason')}. Stop working and tell the user.", stop=True)
        if by == "timeout":
            deny("Squidbrake: nobody approved this in time, so it didn't run. Ask the user to approve it in the "
                 f"dashboard ({GATEWAY_URL}/dashboard) when you try again.")
        if by:
            note = f' Note: "{d["decision_note"]}".' if d.get("decision_note") else ""
            deny(f"Squidbrake: rejected by {by}.{note} Don't retry it; ask the user how to proceed.")
        deny(f"{told}Squidbrake blocked this (rule '{d.get('rule_id')}'): {(d.get('reason') or '').rstrip('.')}. "
             "Don't try to work around it.")

    # Allowed: remember the event so PostToolUse can attach the result, then let Claude Code continue normally.
    if ev.get("tool_use_id"):
        STATE_DIR.mkdir(exist_ok=True)
        (STATE_DIR / f"{ev['tool_use_id']}.json").write_text(json.dumps({"event_id": d["event_id"], "t0": time.time()}))
    copied = keep_copy(ev, line, d, http)
    out: dict = {}
    if told:   # Claude reads it before its next step; the person at the keyboard sees it too
        out["hookSpecificOutput"] = {"hookEventName": "PreToolUse", "additionalContext": told.strip()}
    if copied or told:
        out["systemMessage"] = " ".join(x for x in (told.strip() if told else None, copied) if x)
    if out:
        print(json.dumps(out))


def post(ev: dict, http: httpx.Client, failed: bool = False) -> None:
    """PostToolUse: attach the result. PostToolUseFailure (the tool errored, e.g. a command exited non-zero): the error."""
    f = STATE_DIR / f"{ev.get('tool_use_id')}.json"
    if not f.exists():
        return
    state = json.loads(f.read_text())
    f.unlink(missing_ok=True)
    result = {"duration_ms": (time.time() - state["t0"]) * 1000}
    if failed:
        result["error"] = str(ev.get("error") or "the tool failed")[:2000]
    else:
        result["output"] = ev.get("tool_response")
    try:
        http.post(f"/v1/events/{state['event_id']}/result", json=result)
    except httpx.HTTPError:
        pass  # the tool already ran; never fail Claude Code because reporting failed


def prompt(ev: dict, http: httpx.Client) -> None:
    """Record what the user asked. Never blocks the prompt: if the gateway is down, Claude Code carries on."""
    text = ev.get("prompt")
    if not text:
        return
    try:
        http.post("/v1/events", json={"name": "user.prompt", "kind": "prompt", "input": {"prompt": text},
                                      "output": {"recorded": True}, "source": SOURCE, "session_id": ev.get("session_id")})
    except httpx.HTTPError:
        pass


def _cursor_has_own_hook() -> bool:
    try:
        return "agent_hook.py" in (Path.home() / ".cursor" / "hooks.json").read_text(encoding="utf-8")
    except OSError:
        return False


def main() -> None:
    # UTF-8 whatever the console's code page, with or without a byte-order mark (Windows shells add one)
    text = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace").strip()
    if not text:
        sys.exit(0)                                 # nothing to check
    try:
        ev = json.loads(text)
        if not isinstance(ev, dict):
            raise ValueError("not a JSON object")
    except ValueError:
        if FAIL_OPEN:
            sys.exit(0)
        deny("Squidbrake couldn't read what Claude Code sent to its hook, so this was blocked to be safe. "
             "Tell the user to run: squidbrake doctor")
    if hooklog is not None:
        hooklog.record("cursor-via-claude-code" if ev.get("cursor_version") else "claude-code", str(ev.get("hook_event_name") or ""))
        hooklog.daily(GATEWAY_URL)
    if str(ev.get("tool_name", "")).startswith(SKIP_PREFIXES):
        sys.exit(0)
    if ev.get("cursor_version") and _cursor_has_own_hook():
        sys.exit(0)   # Cursor runs Claude Code's hooks too; its own Squidbrake hook (agent_hook.py) covers it already
    with gateway_client(GATEWAY_URL, GATEWAY_API_KEY, 10) as http:
        if ev.get("hook_event_name") == "PreToolUse":
            pre(ev, http)
        elif ev.get("hook_event_name") == "PostToolUse":
            post(ev, http)
        elif ev.get("hook_event_name") == "PostToolUseFailure":
            post(ev, http, failed=True)
        elif ev.get("hook_event_name") == "UserPromptSubmit":
            prompt(ev, http)
    sys.exit(0)


if __name__ == "__main__":
    main()