diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index bc1e605e1..a7bc14fc0 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.8.0", + "version": "0.9.0", "description": "Eight safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, (advisory) hallucinated CLI flags, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.", "author": { "name": "Melodic Software", @@ -50,6 +50,12 @@ "description": "Block Bash file-write workarounds that circumvent Write/Edit hook gates", "default": true }, + "block_noncanonical_commit_enabled": { + "type": "boolean", + "title": "block-noncanonical-commit guard", + "description": "Block `git commit` that does not pipe its message via `-F -`; --amend, -C/-c, --fixup/--squash, -F , and an in-progress merge/rebase are exempt", + "default": true + }, "cli_flag_verify_enabled": { "type": "boolean", "title": "cli-flag-verify guard", @@ -85,6 +91,12 @@ "title": "block-dangerous-git allow-list", "description": "Comma-separated forms block-dangerous-git permits: push-force, reset-hard, clean-force, checkout-dot, restore-dot, checkout-force; empty blocks all", "default": "" + }, + "block_noncanonical_commit_allow": { + "type": "string", + "title": "block-noncanonical-commit allow-list", + "description": "Comma-separated form tokens to allow (currently: message-flag, which permits a bare `-m`)", + "default": "" } } } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index c745861c5..af5b5f109 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,45 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.9.0] + +### Added + +- **`block-noncanonical-commit` — `git commit` must pipe its message via `-F -`.** The advisory that + previously covered this was overridden 11 times in a single session; an advisory that is always + overridden trains the reader to filter it out. The guard enforces the *mechanic*, not the ritual: + `git commit -m ""` flattens newlines unpredictably across shells, and the stdin form is + what prevents it. Exempt, because no message-on-stdin form exists for them and gating them would + strand real work: `--amend`/`--no-edit`, `-C`/`-c`/`--reuse-message`/`--reedit-message`, + `--fixup`/`--squash`, `-F `, and any commit taken while a merge, rebase, cherry-pick, or + revert is in progress. Kill switch `block_noncanonical_commit_enabled`; allow-list + `block_noncanonical_commit_allow` (`message-flag` permits a bare `-m`). Detection reuses the + argv-grammar-faithful parser, so `bash -lc` wrappers resolve and a commit body merely *mentioning* + `git commit -m` never fires. Aliases are expanded before the subcommand verdict — inline `-c` + (last value wins, as git applies it) and aliases persisted in git config alike — closing the hole + where `git c -m x` reads as subcommand `c` and walks straight through. `--config-env` aliases are a + documented residual: the shared parser stores their value undifferentiated from `-c`, so the + environment variable *name* arrives in place of the expansion (tracked separately). `git -C ` is honored when probing sequencer state, so a conflict + resolution driven at another repo reads that repo's state rather than the session cwd's. + +### Fixed + +- **`flag-commit-pr-skill-bypass` no longer demands `--trailer`.** The old condition required both + `-F -` **and** `--trailer`, but `/commit` omits the trailer when the resolved `trailer_policy` is + `none` — so in a repo whose convention forbids a co-author trailer, the skill's own conformant + output was flagged on every commit. The trailer is policy; only the stdin form is mechanic. This + also had to be settled before the new guard could block on the same condition: requiring + `--trailer` to pass would have permanently blocked `/commit` in that configuration. + +### Changed + +- **`flag-commit-pr-skill-bypass` is now `gh pr create`-only.** The `git commit` branch moved to + `block-noncanonical-commit`, so the two never double-fire on one command. `gh pr create` stays + advisory and cannot become otherwise: `/pull-request create` issues that exact command itself, and + [anthropics/claude-code#22655](https://github.com/anthropics/claude-code/issues/22655) (expose + `skill_name` to hooks) is closed as not planned — a hook cannot tell a skill-driven call from an + ad hoc one, so blocking it would deadlock the skill. + ## [0.8.0] ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index b3ed5928a..d66cfac0c 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -1,6 +1,6 @@ # guardrails -A Claude Code plugin bundling eight **safety guards** that catch risky agent +A Claude Code plugin bundling nine **safety guards** that catch risky agent actions the moment they happen — before a write lands or a bash command runs. Each guard is independently toggleable, so you run exactly the subset you want. @@ -15,9 +15,10 @@ Each guard is independently toggleable, so you run exactly the subset you want. | **block-hook-bypass** | PreToolUse · Bash | **Blocks** (exit 2) | Bash file-write workarounds that circumvent the Write/Edit hook gates — `cat > file`, `echo … > file`, and `python3 -c` with file-write indicators. Executable-token detection ignores quoted prose/commit text that merely mentions the pattern. | | **cli-flag-verify** | PostToolUse · Write \| Edit | **Advisory** (exit 0) | Hallucinated CLI flags — a `--flag` written as a command that does not exist in the binary's actual `--help` output. Surfaces via `additionalContext`, never blocks. | | **workflow-resilience-check** | PreToolUse · Workflow | **Advisory** (exit 0) | Un-throttled Workflow fan-out — a script calling `parallel()` / `pipeline()` with no wave-cap throttle (`inWaves` / `inWavesPipeline`) and no retry wrapper (`agentRetry`), which risks a burst 529 under wide Opus fan-out. Surfaces a resilience checklist via `additionalContext`, never blocks. | -| **flag-commit-pr-skill-bypass** | PreToolUse · Bash | **Advisory** (exit 0) | Direct `git commit` (missing the canonical `-F -` stdin form + `--trailer` Co-Authored-By line) or any `gh pr create`, bypassing this marketplace's own `/commit` / `/pull-request create` skills. Only fires when the consuming project's own `.claude/settings.json` enables the `source-control` plugin — silent otherwise. Surfaces via `additionalContext`, never blocks. | +| **block-noncanonical-commit** | PreToolUse · Bash | **Blocks** (exit 2) | `git commit` that does not pipe its message via `-F -` / `--file -` — `-m` flattens newlines unpredictably across shells. Exempt: `--amend`, `-C`/`-c`/`--reuse-message`/`--reedit-message`, `--fixup`/`--squash`, `-F `, and any commit taken while a merge/rebase/cherry-pick/revert is in progress. Resolves `bash -lc` wrappers and git aliases (inline `-c` and persisted config alike). | +| **flag-commit-pr-skill-bypass** | PreToolUse · Bash | **Advisory** (exit 0) | Any `gh pr create`, bypassing this marketplace's own `/pull-request create` skill. Only fires when the consuming project's own `.claude/settings.json` enables the `source-control` plugin — silent otherwise. Surfaces via `additionalContext`, never blocks. | -The five blocking guards feed their stderr message back to Claude as +The six blocking guards feed their stderr message back to Claude as actionable fix guidance. The three advisory guards surface their findings the same way but always allow the operation. @@ -56,13 +57,21 @@ way but always allow the operation. literal-stripped top-level regex match, not a full argv-grammar parser — it does not evaluate shell variable / command substitution, and a determined author can construct a form that evades it. It cannot tell "the skill ran - this exact command" from "someone hand-typed the same shape" — for - `git commit` it targets the anti-pattern (`-m` without the canonical - `-F -` + `--trailer`), not literal `/commit` invocation; for `gh pr create` - there is no command-shape signature at all, so every direct call is flagged. - Always advisory (never blocks) — `create.md` itself documents a legitimate + this exact command" from "someone hand-typed the same shape", and for + `gh pr create` there is no command-shape signature at all, so every direct + call is flagged. It stays advisory and cannot become otherwise: + `/pull-request create` issues that exact command itself, so blocking it would + deadlock the skill being advertised. `create.md` also documents a legitimate inline fallback when skill discovery is broken. +- **`block-noncanonical-commit` gates shape, not skill invocation.** No hook can + see which skill (if any) originated a Bash call, so "did you run `/commit`" is + not an available condition — and shape is the better target regardless, since + it enforces an outcome verifiable in `git log`. It deliberately does not + require `--trailer`: `/commit` omits the trailer under a resolved + `trailer_policy` of `none`, so demanding it would block the skill's own + conformant output in repos whose convention forbids co-author trailers. + ## Per-hook kill switches Each guard is toggled by its own `userConfig` boolean (default **on**; set to @@ -76,6 +85,7 @@ contract — disable one guard without touching the others. | block-no-verify | `block_no_verify_enabled` | | block-dangerous-git | `block_dangerous_git_enabled` | | block-hook-bypass | `block_hook_bypass_enabled` | +| block-noncanonical-commit | `block_noncanonical_commit_enabled` | | cli-flag-verify | `cli_flag_verify_enabled` | | workflow-resilience-check | `workflow_resilience_check_enabled` | | flag-commit-pr-skill-bypass | `flag_commit_pr_skill_bypass_enabled` | diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh new file mode 100755 index 000000000..d4f87f91d --- /dev/null +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# PreToolUse hook: block `git commit` that does not feed its message via stdin. +# Triggered on Bash tool calls. +# +# WHAT IT ENFORCES — the mechanic, not the ritual: +# `git commit -F -` (or `--file -`), the form the /commit skill emits. The +# failure mode it prevents is real and silent: `git commit -m ""` +# flattens newlines unpredictably across shells, so a body that looked right +# in the tool call lands mangled in history. +# +# WHY NOT `--trailer`: the trailer is POLICY, not mechanic. /commit itself +# omits it when the resolved trailer_policy is `none`, and a repo whose +# convention forbids a co-author trailer is a documented, supported case. +# Gating on it would permanently block the skill's own canonical output in that +# configuration. Only the stdin form belongs in a gate. +# +# WHY NOT "did you type /commit": a hook cannot tell a skill-driven Bash call +# from an ad hoc one — the payload carries no originating-skill field, and the +# upstream request to add one was declined. Gating on command SHAPE is what is +# actually available, and is the better target anyway: it enforces the outcome +# a reviewer can verify in `git log`, not the ceremony that produced it. +# +# NOT BLOCKED (no message-on-stdin form exists for these, and gating them would +# break conflict resolution and history rewriting): +# --amend reusing an existing message +# (--no-edit alone is NOT exempt: `git commit --no-edit -m x` is an +# ordinary commit. Amending is covered by --amend; a merge's --no-edit is +# covered by the sequencer branch.) +# -C / --reuse-message " +# -c / --reedit-message " +# --fixup / --squash message derived from another commit +# -F / --file mechanic satisfied, just not via stdin +# -m during an in-progress sequencer (merge / rebase / cherry-pick / revert): +# conflict resolution and rebase continuation +# must never be gated +# +# Per-repo/per-user allow-list: the `block_noncanonical_commit_allow` userConfig +# option is a comma-separated list of form tokens (currently just +# "message-flag", which allows a bare `-m`). Set it with +# `/plugin configure guardrails` or headless via `claude plugin install +# --config`; read from the CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ALLOW +# process mirror. Kill switch: `block_noncanonical_commit_enabled` set to false. +# +# Detection is ARGV-GRAMMAR-FAITHFUL via the shared parser in hook-utils.sh, so +# a commit body merely MENTIONING `git commit -m` never fires, and `bash -lc` +# wrappers plus git aliases are resolved — inline `-c` (last value wins, as git +# does) and aliases persisted in git config. +# +# RESIDUAL: `--config-env=alias.X=VAR` is not resolved. The shared parser stores +# its value undifferentiated from `-c`, so the environment VARIABLE NAME reaches +# this code in place of the expansion, and separating them needs a hook-utils +# change that block-dangerous-git shares. Static matching over the literal +# command string only: shell variable / command substitution is not evaluated. +# This is a friction guard against the accidental anti-pattern, not a sandbox. +# +# BLOCKING: exits 2 when a commit would take its message off the command line. + +set -uo pipefail + +# shellcheck source=hook-utils.sh +source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" + +hook::check_enabled "BLOCK_NONCANONICAL_COMMIT" + +# High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; +# on older bash it is unset, so default to empty and skip telemetry (the block +# still fires). Referencing it bare under `set -u` would abort before exit. +start=${EPOCHREALTIME:-} + +# jq is required to parse the tool payload. Fail OPEN when it is absent, but +# make the degraded state visible rather than silently disabling the guard. +if ! command -v jq >/dev/null 2>&1; then + echo "guardrails/block-noncanonical-commit: jq not found on PATH — guard disabled (install jq to enable)." >&2 + exit 0 +fi + +# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read. rc 1 +# (empty stdin) skips; rc 2 (read timed out before a complete payload) FAILS +# CLOSED — the guard cannot evaluate the tool call, and a silent skip would pass +# exactly the traffic this guard exists to stop. +INPUT=$(hook::buffer_stdin) || { + rc=$? + ((rc == 2)) && exit 2 + exit 0 +} +COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r') +[[ -n "$COMMAND" ]] || exit 0 +HOOK_CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null | tr -d '\r') + +SUBJECT=$(hook::extract_bash_subject "Bash" "$COMMAND") + +emit_tel() { + [[ -n "$start" ]] || return 0 + hook::telemetry_enabled || return 0 + local data + data=$(jq -n --arg subject "$SUBJECT" --arg form "$2" \ + '{tool:"Bash",subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' + hook::emit_telemetry "block-noncanonical-commit" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" +} + +# Is a form token in the block_noncanonical_commit_allow userConfig comma list? +# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain +allowed() { + local tok="$1" list=",${CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ALLOW:-}," + [[ "$list" == *,"$tok",* ]] +} + +# Effective repo directory for a segment: the hook payload's cwd, with any +# `git -C ` applied (last wins, relative joined onto cwd). Without this a +# conflict resolution driven at another repo via `-C` reads the WRONG repo's +# state — the sequencer probe and the alias lookup would both answer for the +# session cwd instead of the repo actually being committed to. +# Value of an explicit `--git-dir` (attached or separated), empty when absent. +# A commit driven with --git-dir concludes a sequencer in THAT git dir, so +# probing the cwd's state would refuse to exempt a real in-progress merge. +# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain +explicit_git_dir() { + local i n=$# arg + local -a a=("$@") + for ((i = 0; i < n; i++)); do + arg="${a[i]}" + case "$arg" in + --git-dir) + ((i + 1 < n)) && printf '%s' "${a[i + 1]}" + return 0 + ;; + --git-dir=*) + printf '%s' "${arg#--git-dir=}" + return 0 + ;; + *) ;; + esac + done +} + +# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain +effective_dir() { + local base="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}" i n=$# arg + local -a a=("$@") + for ((i = 0; i < n; i++)); do + arg="${a[i]}" + if [[ "$arg" == "-C" ]] && ((i + 1 < n)); then + if [[ "${a[i + 1]}" == /* || "${a[i + 1]}" =~ ^[A-Za-z]:[\/] ]]; then + base="${a[i + 1]}" + else + base="$base/${a[i + 1]}" + fi + ((i++)) + fi + done + printf '%s' "$base" +} + +# Is a merge / rebase / cherry-pick / revert in progress? Those commits carry a +# prepared message git supplies, and `git commit` there is the documented way to +# conclude the operation — gating it would strand a conflict resolution +# mid-flight. Unknown git state answers "no": this is the permissive branch, so +# an uncertain answer must not silently open the gate. +# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain +sequencer_in_progress() { + local dir f repo="$1" explicit="$2" + if [[ -n "$explicit" ]]; then + # An explicit --git-dir names the git dir outright; asking git to resolve it + # would just echo it back, and `-C` may point somewhere unrelated. + dir="$explicit" + [[ "$dir" == /* || "$dir" =~ ^[A-Za-z]:[\/] ]] || dir="$repo/$dir" + else + # --absolute-git-dir, not --git-dir: the latter answers relative to the repo, + # which would resolve against the HOOK's cwd here and silently miss every + # sequencer file. + dir=$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null) || return 1 + fi + [[ -n "$dir" ]] || return 1 + for f in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply; do + [[ -e "$dir/$f" ]] && return 0 + done + return 1 +} + +# shellcheck disable=SC2329 # invoked indirectly as the hook::bash_parse_segments callback +check_segment() { + local -a w=() + local gi sub sub_idx nseg k word next stdin_form=0 exempt=0 saw_commit=0 + local inline_alias_handled=0 + + # A shell -c wrapper (`bash -lc 'git commit -m x'`) executes its operand as a + # full shell command — re-parse it with the same tokenizer. + if hook::shell_c_operand "$@"; then + hook::bash_parse_segments "$HOOK_SHELL_C_OPERAND" check_segment + return 0 + fi + + hook::git_resolve_index "$@" || return 0 + gi=$HOOK_GIT_RESOLVED_GI + w=("${HOOK_GIT_RESOLVED_WORDS[@]}") + nseg=${#w[@]} + + hook::git_resolve_subcommand "$gi" "${w[@]}" || return 0 + sub=$HOOK_GIT_SUB + sub_idx=$HOOK_GIT_SUB_IDX + + # An inline alias runs its expansion (`git -c alias.c=commit c -m x` commits), + # so re-check the expanded command BEFORE concluding the subcommand is not + # `commit` — otherwise the alias name is simply not "commit" and the guard + # waves it through. A shell alias (leading !) re-parses as a full shell + # command; a git alias splices its words in place of the alias name. One level + # only — git does not expand the first word of an expansion as another alias — + # enforced through HOOK_NO_ALIAS, which dynamic scoping carries into the + # recursive call. + if ((${HOOK_NO_ALIAS:-0} == 0)); then + local cv exp reparse a + local -a cfgv=() expw=() + cfgv=(${HOOK_GIT_CONFIG_VALUES[@]+"${HOOK_GIT_CONFIG_VALUES[@]}"}) + # LAST value wins, matching git: `-c alias.c=status -c alias.c=commit` + # runs commit. Taking the first match would let a decoy earlier value + # (expanding to a harmless subcommand) mask the real one. + exp="" + for cv in ${cfgv[@]+"${cfgv[@]}"}; do + [[ "$cv" == "alias.${sub}="* ]] && exp="${cv#*=}" + done + if [[ -n "$exp" ]]; then + inline_alias_handled=1 + if [[ "$exp" == '!'* ]]; then + reparse="${exp#!}" + for a in "${w[@]:sub_idx+1}"; do reparse+=" $(printf '%q' "$a")"; done + hook::bash_parse_segments "$reparse" check_segment + else + hook::env_s_split "$exp" + expw=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"}) + HOOK_NO_ALIAS=1 + check_segment "${w[@]:0:gi+1}" ${expw[@]+"${expw[@]}"} "${w[@]:sub_idx+1}" + HOOK_NO_ALIAS=0 + fi + fi + + # An alias can also live in .git/config, ~/.gitconfig, or system config, + # where HOOK_GIT_CONFIG_VALUES cannot see it — `git config alias.c commit` + # then `git c -m x` would otherwise pass. Ask git for the resolved value + # (its own precedence applies) only when no inline alias already matched. + if ((${HOOK_NO_ALIAS:-0} == 0)) && ((inline_alias_handled == 0)) && [[ "$sub" != "commit" ]]; then + local pexp + pexp=$(git -C "$(effective_dir "${w[@]}")" config --get "alias.$sub" 2>/dev/null) + if [[ -n "$pexp" ]]; then + if [[ "$pexp" == '!'* ]]; then + local preparse pa + preparse="${pexp#!}" + for pa in "${w[@]:sub_idx+1}"; do preparse+=" $(printf '%q' "$pa")"; done + hook::bash_parse_segments "$preparse" check_segment + else + local -a pexpw=() + hook::env_s_split "$pexp" + pexpw=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"}) + HOOK_NO_ALIAS=1 + check_segment "${w[@]:0:gi+1}" ${pexpw[@]+"${pexpw[@]}"} "${w[@]:sub_idx+1}" + HOOK_NO_ALIAS=0 + fi + fi + fi + fi + + [[ "$sub" == "commit" ]] || return 0 + saw_commit=1 + + # Scan only the words AFTER the subcommand: a top-level `git -c foo=bar` is + # config, while `-c` after `commit` is --reedit-message. + for ((k = sub_idx + 1; k < nseg; k++)); do + word="${w[k]}" + next="" + ((k + 1 < nseg)) && next="${w[k + 1]}" + case "$word" in + --) + break + ;; + -F | --file) + if [[ "$next" == "-" ]]; then stdin_form=1; else exempt=1; fi + ((k++)) + ;; + -F- | --file=-) + stdin_form=1 + ;; + --file=*) + exempt=1 + ;; + -F*) + exempt=1 + ;; + --amend | --fixup | --squash | -C | -c | --reuse-message | --reedit-message) + exempt=1 + ;; + --fixup=* | --squash=* | --reuse-message=* | --reedit-message=*) + exempt=1 + ;; + -C* | -c*) + exempt=1 + ;; + *) + # Any other word (paths, -a, -S, --cleanup, the -m payload) is not a + # message-source marker and needs no handling here. + ;; + esac + done + + ((saw_commit)) || return 0 + ((stdin_form || exempt)) && return 0 + allowed "message-flag" && return 0 + sequencer_in_progress "$(effective_dir "${w[@]}")" "$(explicit_git_dir "${w[@]}")" && return 0 + + echo "BLOCKED: \`git commit\` without \`-F -\` — the message must be piped via stdin." >&2 + echo "Use the /commit skill (source-control plugin), or its canonical form directly:" >&2 + echo " git commit -F - --cleanup=verbatim <<'EOF'" >&2 + echo " " >&2 + echo " EOF" >&2 + echo "A \`-m\` message flattens newlines unpredictably across shells. --amend, -C/-c," >&2 + echo "--fixup/--squash, -F , and an in-progress merge/rebase are exempt." >&2 + emit_tel "blocked" "message-flag" + exit 2 +} + +hook::bash_parse_segments "$COMMAND" check_segment + +emit_tel "ok" "" +exit 0 diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh new file mode 100755 index 000000000..f3166e758 --- /dev/null +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# Contract test for block-noncanonical-commit.sh (guardrails plugin). +# +# Black-box: invokes the hook as a subprocess, pipes PreToolUse Bash JSON on +# stdin, asserts on exit code (2 = blocked, 0 = allowed). Self-contained — no +# host-repo assertion library. + +set -uo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$HOOK_DIR/block-noncanonical-commit.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# shellcheck source=guardrails-test-helpers.sh +source "$HOOK_DIR/guardrails-test-helpers.sh" + +# Isolate every git fixture below from ambient user/system config. Exported once +# here rather than inside each subshell, so the setting is visibly process-wide +# and the fixtures stay comparable. +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null + +# run