Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
25 changes: 25 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions hooks/gatecat-python.sh
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions hooks/gatecat_bootstrap.py
Original file line number Diff line number Diff line change
@@ -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())
79 changes: 79 additions & 0 deletions hooks/gatecat_veto_hook.py
Original file line number Diff line number Diff line change
@@ -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())
28 changes: 28 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
]
}
}