SkillsHooksPromptsAgentsPersonasModelsPoliciesToolsTemplatesBundlesCategoriesStart here
← Hooks
Hk hookgovernanceblockingstable

Destructive GitHub CLI Guard

Blocks high-blast-radius, irreversible gh CLI operations. Denies without explicit --force-i-mean-it confirmation: gh repo delete, gh release delete, gh secret delete, and gh auth logout. All other gh subcommands pass through.

id hook/destructive-gh-guardv1.0.1by convergent-systems-key
Event
PreToolUse
Trigger
tool-nameBash
Language
python
Side effects
  • blocks tool call with explanation and bypass instructions
Platforms
linuxmacoswindows
Notes
Logic is cross-platform. Wiring: use 'ai hooks run destructive-gh-guard' in settings.json — the ai binary discovers Python on each OS. gh CLI available on all platforms. Python logic is cross-platform.
Depends on
hook/lib

Script · destructive-gh-guard.py

#!/usr/bin/env python3
"""hooks/destructive-gh-guard.py — gate destructive `gh` operations
per Common.md §2.2.

Blocks (without --force-i-mean-it confirmation):

  gh repo delete <repo>
  gh release delete <tag>
  gh secret delete <name>
  gh auth logout

These are the high-blast-radius / irreversible operations on the
default `gh` surface. Other subcommands pass through.

Self-check:
  --self-check
"""
from __future__ import annotations

import argparse
import os
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib  # noqa: E402


GUARDED = [
    ("repo", "delete"),
    ("release", "delete"),
    ("secret", "delete"),
    ("auth", "logout"),
]

# Escape hatch: the user can set AI_ALLOW_DESTRUCTIVE_GH=1 for a single
# session to bypass these checks. Setting this is itself a §2.2 action
# and is logged.
BYPASS_ENV = "AI_ALLOW_DESTRUCTIVE_GH"


def check_invocation(argv: list[str]) -> int:
    """argv is the args AFTER `gh`."""
    if len(argv) < 2:
        return 0
    a, b = argv[0], argv[1]
    if (a, b) not in GUARDED:
        return 0

    if os.environ.get(BYPASS_ENV) == "1":
        _lib.log(f"`gh {a} {b}` — bypass active ({BYPASS_ENV}=1). Logged.")
        return 0

    _lib.log(f"blocking — `gh {a} {b}` is a §2.2 destructive operation.")
    _lib.log("Per Common.md §2.2 + §2.4: name what will be destroyed, snapshot if reversible, wait for an unambiguous yes.")
    _lib.log(f"To bypass for one session only: AI_ALLOW_DESTRUCTIVE_GH=1 gh {a} {b} ...")
    return 1


def argv_from_wrapper() -> list[str]:
    """The args after `gh`, as JSON in WRAPPED_ARGV (command-wrapper path).

    The wrapper does not forward the tool's argv on the command line — it
    publishes it in WRAPPED_ARGV so argparse never sees subcommand tokens.
    """
    import json
    try:
        return json.loads(os.environ.get("WRAPPED_ARGV", "[]"))
    except json.JSONDecodeError:
        return []


def from_claude_payload() -> int:
    import json
    raw = sys.stdin.read()
    if not raw.strip():
        return 0
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return 0
    cmd = (
        payload.get("command")
        or payload.get("tool_input", {}).get("command")
        or ""
    ) if isinstance(payload, dict) else ""
    if not cmd.strip().startswith("gh "):
        return 0
    return check_invocation(cmd.split()[1:])


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(add_help=True)
    parser.add_argument("--self-check", action="store_true")
    parser.add_argument("--mode", choices=["claude", "wrapper"], default=None)
    parser.add_argument("rest", nargs=argparse.REMAINDER)
    args = parser.parse_args(argv)

    if args.self_check:
        return _lib.self_check_ok()
    if args.mode == "wrapper":
        return check_invocation(argv_from_wrapper())
    if args.mode == "claude" or (args.mode is None and not sys.stdin.isatty()):
        return from_claude_payload()
    return check_invocation(args.rest)


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
governancegh-clidestructiveguardclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.