#!/usr/bin/env bash # hooks/global/ticket-gate.sh — GLOBAL crush PreToolUse gate (all lanes). # # Ticket-first policy, repo-scoped: inside a git repository, modifying # operations are blocked until the repo's .crush/active-ticket is set # echo '#NNN' > .crush/active-ticket (set) # > .crush/active-ticket (clear) # Outside git repos (scratch space) everything is allowed: the gate # governs governed work, not throwaway experiments. # # Env provided by crush: CRUSH_TOOL_NAME, CRUSH_TOOL_INPUT_COMMAND, # CRUSH_TOOL_INPUT_FILE_PATH, CRUSH_PROJECT_DIR, PWD. set -u TOOL="${CRUSH_TOOL_NAME:-}" CMD="${CRUSH_TOOL_INPUT_COMMAND:-}" FILE_PATH="${CRUSH_TOOL_INPUT_FILE_PATH:-}" # Read-only tools — always allowed, everywhere. case "$TOOL" in view|ls|grep|glob|agent|sourcegraph|fetch|agentic_fetch|download|lsp_diagnostics|lsp_symbols|lsp_definition|lsp_references|lsp_call_hierarchy|crush_info|crush_logs|question|todos) exit 0 ;; esac # Not inside a git repo? Scratch space — allow. REPO_ROOT="$(git -C "${PWD:-.}" rev-parse --show-toplevel 2>/dev/null || true)" if [ -z "$REPO_ROOT" ]; then exit 0 fi # bash tool: exempt read-only + management commands. if [ "$TOOL" = "bash" ]; then case "$CMD" in *"redmine-cli"*|*"discourse-cli"*|*"dns-cli"*|*"technitium"*) exit 0 ;; *"git status"*|*"git log"*|*"git diff"*|*"git show"*|*"git branch"*) exit 0 ;; *"check-rules"*|*"setup-hooks"*|*"shellcheck"*|*"run-tests"*|*"promote.sh"*) exit 0 ;; *"tailscale status"*|*"access-matrix"*|*"docker ps"*|*"docker logs"*|*"docker inspect"*) exit 0 ;; *active-ticket*) exit 0 ;; *active-cr*|*"glpi-change"*) exit 0 ;; esac fi # edit/write: policy and wiring files ARE the policy — exempt them. case "$FILE_PATH" in */AGENTS.md|*/questions-v*.md|*/check-rules.sh|*/crush.json|*/crushrc|*/hooks/*|*/.crush/*) case "$TOOL" in write|edit|multiedit) exit 0 ;; esac ;; esac # Enforce: repo root's .crush/active-ticket must exist and be non-empty. TICKET_FILE="$REPO_ROOT/.crush/active-ticket" if [ -f "$TICKET_FILE" ] && [ -s "$TICKET_FILE" ]; then TICKET="$(tr -d '\n' < "$TICKET_FILE")" printf '{"context":"Active ticket: %s"}\n' "$TICKET" else cat >&2 <<'EOF' {"error":{"message":"TICKET GATE: no active ticket in this repo. Set one first: echo '#NNN' > .crush/active-ticket (create the ticket in Redmine first if none exists). Clear with: > .crush/active-ticket"}} EOF exit 2 fi # CHANGE GATE [#767] (t/325 §5 ladder; Q3 ruling 2026-09-04): prod-target # remote operations additionally require a filed GLPI Change — its id in # .crush/active-cr (clear when the CR closes: > .crush/active-cr) # Dev-lane targets are exempt (sectestbed/preprod/test/sandbox = Tier 0/1). # CR tooling: KNEL/inventory scripts/glpi-change.sh (create --agent). if [ "$TOOL" = "bash" ]; then case "$CMD" in *"remote.sh vm-file"*|*"remote.sh prox-file"*|*"remote.sh vm-copy"*|*"remote.sh prox-copy"*|*"remote.sh vm"*|*"remote.sh prox"*|*"remote-dns.sh "*) PROD=1 case "$CMD" in *sectestbed*|*preprod*|*glpi-test*|*kali*|*tsys5*|*sandbox*) PROD=0 ;; esac if [ "$PROD" -eq 1 ]; then CR_FILE="$REPO_ROOT/.crush/active-cr" if [ -f "$CR_FILE" ] && [ -s "$CR_FILE" ]; then printf '{"context":"Active CR: %s"}\n' "$(tr -d '\n' < "$CR_FILE")" else cat >&2 <<'EOF' {"error":{"message":"CHANGE GATE: prod-target remote operation requires a filed GLPI Change. File one: echo 'what/why/where' | mglpi --config ~/.creds/mglpi-agent.env change create --title '[#NNN] summary' --content - — then: echo '' > .crush/active-cr. Dev-lane targets (sectestbed/preprod/test/tsys5) are exempt. Clear with: > .crush/active-cr"}} EOF exit 2 fi fi ;; esac fi exit 0