diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..0c0bd07 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "gate-cat", + "description": "gate.cat — a deterministic, fail-closed veto that blocks an AI coding agent's catastrophic shell commands before they run.", + "owner": { + "name": "gate.cat", + "email": "bogumil@jankiewi.cz", + "url": "https://gate.cat" + }, + "plugins": [ + { + "name": "gate-cat", + "source": "./", + "description": "Deterministic PreToolUse veto for Claude Code. Fail-closed deny-list that blocks catastrophic Bash/Write/Edit actions (rm -rf under a protected root, disk wipe, prod-DB drop, secret exfil, force-push, runaway loops) before they execute. Pattern/rule-based, not an LLM, so it enforces a curated deny-list and can miss commands outside it. Apache-2.0. Free local core; optional signed policy packs and cloud sync at https://gate.cat.", + "category": "security", + "author": { + "name": "gate.cat", + "email": "bogumil@jankiewi.cz" + }, + "homepage": "https://gate.cat" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..768285f --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "gate-cat", + "displayName": "gate.cat", + "version": "0.4.18", + "description": "Deterministic, fail-closed veto for AI coding agents. A PreToolUse hook that blocks catastrophic Bash/Write/Edit actions (rm -rf under a protected root, disk wipe, prod-DB drop, secret exfil, git force-push, curl|sh, runaway loops) before they execute — exit 2 with the reason fed back to the model. Pattern/rule-based, not an LLM: it enforces a curated deny-list and can miss commands outside it; every allow AND block is logged to ~/.gatecat/veto_log.jsonl for false-block adjudication.", + "author": { + "name": "gate.cat", + "email": "bogumil@jankiewi.cz", + "url": "https://gate.cat" + }, + "homepage": "https://gate.cat", + "repository": "https://github.com/BGMLAI/gate.cat", + "license": "Apache-2.0", + "keywords": [ + "security", + "agent-safety", + "guardrails", + "veto", + "deny-list", + "hooks", + "pretooluse", + "fail-closed" + ], + "hooks": "./hooks/hooks.json" +} diff --git a/README.md b/README.md index 95c11c3..9c8deef 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,22 @@ see what it's watched and stopped. Fail-closed: a missing or erroring engine blocks rather than allowing. In a throwaway CI/sandbox it disarms itself and logs a no-op (`GATECAT_VETO_EPHEMERAL=0` forces it armed). +### One-command install (Claude Code plugin) + +Prefer not to edit `settings.json` by hand? gate.cat ships as a native Claude +Code plugin. From inside Claude Code: + +``` +/plugin marketplace add BGMLAI/gate.cat +/plugin install gate-cat@gate-cat +``` + +That registers the same `PreToolUse` veto for `Bash|Write|Edit`. The +(dependency-free) engine is fetched into a plugin-managed venv on the next +`SessionStart`, so there's nothing else to install — ask the agent to run +`rm -rf ~/project` and watch it get blocked. Same fail-closed guarantee: if the +engine can't load, the action is blocked, not waved through. + Framework adapters (crewAI / LangGraph) exist too, plus a framework-agnostic `guard_callable` that wraps any plain callable — that is the supported route for AutoGen and anything else, and there is no AutoGen-specific adapter. All of them diff --git a/hooks/gatecat-python.sh b/hooks/gatecat-python.sh new file mode 100755 index 0000000..474dd01 --- /dev/null +++ b/hooks/gatecat-python.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# gate.cat plugin — find a working Python 3.10+ interpreter and exec the given +# script with it. gate.cat requires Python >=3.10 (requires-python in +# pyproject); below that the package cannot be imported at all. +# +# Adapted from the claude-plugins-official security-guidance shim, which +# handles three real cross-platform breakages: +# * Windows Microsoft Store `python3` stub (exits 49 silently in non-TTY +# subprocess) — probe each candidate with `-c ""` and skip failures. +# * Git Bash POSIX paths (`/c/Users/...`) fed to a native `python.exe` — +# convert to Windows form with `cygpath -w` before exec. +# * cp1252 default encoding on Windows crashing text IO on non-latin bytes — +# force PEP 540 UTF-8 mode. +# +# Args after the shim path are passed straight through to the interpreter: +# bash "${CLAUDE_PLUGIN_ROOT}/hooks/gatecat-python.sh" \ +# "${CLAUDE_PLUGIN_ROOT}/hooks/gatecat_veto_hook.py" +set -e + +# PEP 540: force UTF-8 for all Python IO. No-op on macOS/Linux (already UTF-8); +# on Windows this prevents cp1252 crashes on non-latin path/filename bytes. +export PYTHONUTF8=1 + +# Git Bash hands script paths in POSIX form (`/c/Users/...`); a native +# python.exe would read the leading `/` as a drive root. Convert to Windows +# form. `cygpath` is a Git Bash builtin, absent on macOS/Linux (guard = no-op). +if command -v cygpath >/dev/null 2>&1; then + converted=() + for a in "$@"; do + case "$a" in + /*) converted+=("$(cygpath -w "$a")") ;; + *) converted+=("$a") ;; + esac + done + set -- "${converted[@]}" +fi + +probe() { + "$@" -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")' 2>/dev/null +} + +# True iff "M.m" version string >= 3.10 (gate.cat requires-python). +is_compatible() { + case "$1" in + 3.1[0-9]|3.[2-9][0-9]|[4-9].*|[1-9][0-9].*) return 0 ;; + *) return 1 ;; + esac +} + +# Pass 1 — explicit minor-versioned binaries, highest first. +for cmd in "python3.13" "python3.12" "python3.11" "python3.10"; do + v=$(probe "$cmd") || continue + if is_compatible "$v"; then exec "$cmd" "$@"; fi +done + +# Pass 2 — bare interpreters, only if >= 3.10. +for cmd in "python3" "python" "py -3"; do + # shellcheck disable=SC2086 + v=$(probe $cmd) || continue + # shellcheck disable=SC2086 + if is_compatible "$v"; then exec $cmd "$@"; fi +done + +# Pass 3 — any Python 3 as a last resort. gate.cat can't import under <3.10, +# so the veto hook will then fail CLOSED (exit 2) with clear guidance rather +# than silently pass — which is the correct security posture. We still hand +# off so the hook can emit that guidance from Python. +for cmd in "python3" "python" "py -3"; do + # shellcheck disable=SC2086 + v=$(probe $cmd) || continue + case "$v" in + [0-9]*.[0-9]*) + # shellcheck disable=SC2086 + exec $cmd "$@" ;; + esac +done + +echo "gate.cat: no working Python 3 interpreter found (need >=3.10)." >&2 +echo " tried: python3.13, python3.12, python3.11, python3.10, python3, python, py -3" >&2 +echo " install Python 3.10+ from https://python.org (on Windows, NOT the Microsoft Store)." >&2 +# Fail closed: a Bash/Write/Edit veto that cannot run must not wave the action through. +exit 2 diff --git a/hooks/gatecat_bootstrap.py b/hooks/gatecat_bootstrap.py new file mode 100755 index 0000000..d529d51 --- /dev/null +++ b/hooks/gatecat_bootstrap.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""gate.cat plugin SessionStart bootstrap. + +Ensures the gate.cat veto engine is importable so the PreToolUse hook works +right after ``/plugin install`` — the user does not have to ``pip install`` +anything themselves. Idempotent and non-fatal: + + * If ``gatecat`` already imports in the current interpreter (a global/user + install) -> no-op, exit 0. + * Else build a dedicated venv under ${CLAUDE_PLUGIN_DATA} (falls back to + ~/.gatecat/plugin) and ``pip install gate.cat`` into it. gate.cat's core + has ZERO dependencies, so this is a small, fast download. + * Any failure (offline, no ensurepip, etc.) is reported on stderr and exits + 0 — a SessionStart hook must never block the session. If the engine is + still missing when a Bash/Write/Edit fires, the PreToolUse hook fails + CLOSED with its own guidance. + +The venv lives under the plugin DATA dir (persists across plugin updates), so +this pays the install cost once, not on every update. +""" +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +PACKAGE = os.environ.get("GATECAT_PACKAGE", "gate.cat") + + +def _data_dir() -> Path: + d = os.environ.get("CLAUDE_PLUGIN_DATA") + return Path(d) if d else (Path.home() / ".gatecat" / "plugin") + + +def _venv_python(venv: Path) -> Path: + if os.name == "nt": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" + + +def main() -> int: + # 1) Already importable here? Nothing to do. + if importlib.util.find_spec("gatecat") is not None: + return 0 + + venv = _data_dir() / "venv" + vpy = _venv_python(venv) + + # 2) Already built in a prior session and still imports? + if vpy.exists(): + try: + r = subprocess.run([str(vpy), "-c", "import gatecat"], capture_output=True) + if r.returncode == 0: + return 0 + except Exception: + pass # rebuild below + + # 3) Build the venv + install the (dependency-free) engine. + try: + venv.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [sys.executable, "-m", "venv", str(venv)], + check=True, capture_output=True, timeout=120, + ) + subprocess.run( + [str(vpy), "-m", "pip", "install", "--disable-pip-version-check", + "--upgrade", PACKAGE], + check=True, capture_output=True, timeout=600, + ) + sys.stderr.write( + "gate.cat: veto engine installed for the Claude Code plugin " + f"({venv}). Dangerous Bash/Write/Edit actions will now be gated.\n" + ) + except Exception as exc: # noqa: BLE001 — never block SessionStart + sys.stderr.write( + "gate.cat: could not auto-install the veto engine " + f"({exc!r}). Install it manually so the gate can enforce: " + "pip install gate.cat\n" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/gatecat_veto_hook.py b/hooks/gatecat_veto_hook.py new file mode 100755 index 0000000..cd1ca6b --- /dev/null +++ b/hooks/gatecat_veto_hook.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""gate.cat Claude Code PLUGIN veto hook (PreToolUse). + +Thin launcher that resolves the gate.cat veto engine and delegates to the +packaged, battle-tested ``gatecat.hooks.claude_code.main`` (the SAME engine as +the ``gatecat-hook`` console script — this plugin does not reimplement the +gate, it only wires it into Claude Code's plugin system). + +Resolution order: + 1. Engine importable in the interpreter chosen by gatecat-python.sh? + -> run it directly (covers a global/user ``pip install gate.cat``). + 2. Else re-exec the engine with the plugin-managed venv's python, which + ``gatecat_bootstrap.py`` populates on SessionStart (frictionless install: + the user only ran ``/plugin install`` — the engine was fetched for them). + 3. Else FAIL CLOSED (exit 2) with clear guidance. gate.cat never waves an + action through it could not inspect. + +Contract (Claude Code hooks API): stdin = one JSON tool call; exit 0 = no +opinion (Claude Code's own permission flow proceeds); exit 2 = BLOCK, stderr +fed back to the model as the reason. +""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +# Re-exec payload: import + run the packaged engine. main() reads stdin itself, +# and the subprocess inherits this process's stdin/stdout/stderr, so the JSON +# tool call and the exit code flow through untouched. +_RUN = "from gatecat.hooks.claude_code import main; import sys; sys.exit(main())" + + +def _plugin_venv_python() -> "Path | None": + data = os.environ.get("CLAUDE_PLUGIN_DATA") or str(Path.home() / ".gatecat" / "plugin") + if os.name == "nt": + cand = Path(data) / "venv" / "Scripts" / "python.exe" + else: + cand = Path(data) / "venv" / "bin" / "python" + return cand if cand.exists() else None + + +def _fail_closed(exc: "BaseException | None") -> int: + msg = ( + "gate.cat VETO [ENGINE_UNAVAILABLE]: cannot load the veto engine " + "(fail-closed) — the action is BLOCKED rather than run unchecked. " + "The plugin installs the engine on SessionStart; if that did not run, " + "install it manually: pip install gate.cat" + ) + if exc is not None: + msg += f" [{exc!r}]" + sys.stderr.write((msg + "\n").encode("ascii", "backslashreplace").decode("ascii")) + return 2 + + +def main() -> int: + engine_main = None + import_err: "BaseException | None" = None + try: + from gatecat.hooks.claude_code import main as engine_main # type: ignore + except BaseException as exc: # noqa: BLE001 — missing engine must not pass + import_err = exc + + if engine_main is not None: + return engine_main() + + vpy = _plugin_venv_python() + if vpy is not None: + try: + return subprocess.run([str(vpy), "-c", _RUN]).returncode + except BaseException as exc: # noqa: BLE001 + return _fail_closed(exc) + + return _fail_closed(import_err) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..4b42129 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,28 @@ +{ + "description": "gate.cat — deterministic fail-closed veto. SessionStart ensures the (zero-dependency) veto engine is installed; PreToolUse runs every Bash/Write/Edit through the gate and blocks catastrophic actions before they execute (exit 2, reason on stderr).", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/gatecat-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/gatecat_bootstrap.py\"", + "timeout": 300 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/gatecat-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/gatecat_veto_hook.py\"", + "timeout": 30 + } + ] + } + ] + } +}