diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fc7716508..3bc996ba3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -54,6 +54,12 @@ "source": "./plugins/actionlint", "category": "formatting", "tags": ["actionlint", "github-actions", "workflow", "yaml", "linter", "hook"] + }, + { + "name": "guardrails", + "source": "./plugins/guardrails", + "category": "security", + "tags": ["guard", "security", "secrets", "hardcoded-paths", "git", "cli-flags", "hook"] } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07665c0ac..00878f629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,10 @@ jobs: persist-credentials: false - name: Check for machine-specific paths uses: melodic-software/ci-workflows/.github/actions/machine-specific-paths@2275e82c502d29b468e3ee2b9c11b2de69b1677f # b6431a1 2026-06-26 + with: + # The guardrails plugin bundles a path-detection pattern lib whose + # regex bodies self-match this lane's own detector. + exclude: ':(exclude)plugins/guardrails/lib/path-detection/**' eol-renormalize: runs-on: ubuntu-latest diff --git a/README.md b/README.md index eb40a784b..201383641 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Browse and manage with `/plugin`. To refresh after updates: `/plugin marketplace | [`desktop-notification`](plugins/desktop-notification) | Hook | Alerts you when Claude Code needs input via an audible bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts. | | [`powershell-format`](plugins/powershell-format) | Hook | Formats and lints PowerShell on edit via PSScriptAnalyzer, only when the repo opts in with a `PSScriptAnalyzerSettings.psd1`, using the consuming repo's own analyzer settings. | | [`actionlint`](plugins/actionlint) | Hook | Lints GitHub Actions workflow files (`.github/workflows/*.yml`/`.yaml`) on edit via the `actionlint` already on your `PATH` — advisory findings, never blocking. | +| [`guardrails`](plugins/guardrails) | Hook | Bundles four independently-toggleable PreToolUse safety guards: secret-pattern detection, hardcoded machine-path check, git hook-bypass blocking (`--no-verify`, `core.hooksPath`, `LEFTHOOK=0`), and advisory CLI-flag verification. | Install one: `/plugin install @melodic-software`. diff --git a/docs/conventions/hook-telemetry/README.md b/docs/conventions/hook-telemetry/README.md index a90cc3164..4096c381c 100644 --- a/docs/conventions/hook-telemetry/README.md +++ b/docs/conventions/hook-telemetry/README.md @@ -154,3 +154,7 @@ producers without coordinating with them or each other. |----------|--------------|-------------| | `markdown-formatter` plugin | `markdown-format` | `data/markdown-format.schema.json` | | `desktop-notification` plugin | `desktop-notification` | `data/desktop-notification.schema.json` | +| `guardrails` plugin | `secret-pattern-detection` | `data/secret-pattern-detection.schema.json` | +| `guardrails` plugin | `hardcoded-path-check` | `data/hardcoded-path-check.schema.json` | +| `guardrails` plugin | `cli-flag-verify` | `data/cli-flag-verify.schema.json` | +| `guardrails` plugin | `block-no-verify` | `data/block-no-verify.schema.json` | diff --git a/docs/conventions/hook-telemetry/data/block-no-verify.schema.json b/docs/conventions/hook-telemetry/data/block-no-verify.schema.json new file mode 100644 index 000000000..1bc45100a --- /dev/null +++ b/docs/conventions/hook-telemetry/data/block-no-verify.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/block-no-verify.schema.json", + "title": "block-no-verify telemetry data", + "description": "Per-hook `data` payload for the block-no-verify guard. Discovered from the envelope `hook` value \"block-no-verify\". Evolves additive-only. Carries NO full command string.", + "type": "object", + "required": ["tool", "subject", "form"], + "additionalProperties": true, + "properties": { + "tool": { + "type": "string", + "description": "Always \"Bash\" — this guard matches only Bash tool calls." + }, + "subject": { + "type": "string", + "description": "Privacy-safe command subject `Bash:` with leading sudo / env-assignment prefixes stripped and the token basenamed. NEVER the full command or its arguments." + }, + "form": { + "type": "string", + "description": "The bypass form when blocked: \"no-verify\" | \"hooksPath\" | \"hook-manager-env\" | \"too-long\" (command exceeded the parse cap and was blocked fail-closed). Empty string when the command was allowed (status ok)." + } + } +} diff --git a/docs/conventions/hook-telemetry/data/cli-flag-verify.schema.json b/docs/conventions/hook-telemetry/data/cli-flag-verify.schema.json new file mode 100644 index 000000000..dbd52c3ac --- /dev/null +++ b/docs/conventions/hook-telemetry/data/cli-flag-verify.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/cli-flag-verify.schema.json", + "title": "cli-flag-verify telemetry data", + "description": "Per-hook `data` payload for the cli-flag-verify advisory hook. Discovered from the envelope `hook` value \"cli-flag-verify\". Evolves additive-only.", + "type": "object", + "required": ["tool", "file", "findings"], + "additionalProperties": true, + "properties": { + "tool": { + "type": "string", + "description": "Claude Code tool that triggered the hook. May be an empty string — this advisory hook resolves only the file path, not the tool name." + }, + "file": { + "type": "string", + "description": "Path of the scanned file, relative to the consuming repo root when resolvable." + }, + "findings": { + "type": "array", + "items": { "type": "string" }, + "description": "Unknown (likely-hallucinated) CLI invocations, each ` [...] ` (public CLI identifiers, not secret). Empty array when every scanned flag verified (status ok)." + } + } +} diff --git a/docs/conventions/hook-telemetry/data/hardcoded-path-check.schema.json b/docs/conventions/hook-telemetry/data/hardcoded-path-check.schema.json new file mode 100644 index 000000000..b98b4cfc6 --- /dev/null +++ b/docs/conventions/hook-telemetry/data/hardcoded-path-check.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/hardcoded-path-check.schema.json", + "title": "hardcoded-path-check telemetry data", + "description": "Per-hook `data` payload for the hardcoded-path-check guard. Discovered from the envelope `hook` value \"hardcoded-path-check\". Evolves additive-only. Carries NO matched path — only category labels.", + "type": "object", + "required": ["tool", "file", "violations"], + "additionalProperties": true, + "properties": { + "tool": { + "type": "string", + "description": "Claude Code tool that triggered the hook (Write, Edit, or NotebookEdit)." + }, + "file": { + "type": "string", + "description": "Path of the write target, relative to the consuming repo root when resolvable." + }, + "violations": { + "type": "array", + "items": { "type": "string" }, + "description": "Path-category labels detected in the new content (e.g. \"Linux user path detected\", \"Windows user path detected\"), one per category. NEVER the matched machine-specific path. Empty array on a clean scan (status ok)." + } + } +} diff --git a/docs/conventions/hook-telemetry/data/secret-pattern-detection.schema.json b/docs/conventions/hook-telemetry/data/secret-pattern-detection.schema.json new file mode 100644 index 000000000..505f28f45 --- /dev/null +++ b/docs/conventions/hook-telemetry/data/secret-pattern-detection.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/secret-pattern-detection.schema.json", + "title": "secret-pattern-detection telemetry data", + "description": "Per-hook `data` payload for the secret-pattern-detection guard. Discovered from the envelope `hook` value \"secret-pattern-detection\". Evolves additive-only. Carries NO secret material — only category labels.", + "type": "object", + "required": ["tool", "file", "violations"], + "additionalProperties": true, + "properties": { + "tool": { + "type": "string", + "description": "Claude Code tool that triggered the hook (Write, Edit, or NotebookEdit)." + }, + "file": { + "type": "string", + "description": "Path of the write target, relative to the consuming repo root when resolvable." + }, + "violations": { + "type": "array", + "items": { "type": "string" }, + "description": "Secret-category labels detected in the new content (e.g. \"AWS Access Key\", \"GitHub PAT\"), one per matched pattern. NEVER the secret value or the matched line. Empty array on a clean scan (status ok)." + } + } +} diff --git a/docs/conventions/hook-telemetry/examples/block-no-verify.json b/docs/conventions/hook-telemetry/examples/block-no-verify.json new file mode 100644 index 000000000..0281c962a --- /dev/null +++ b/docs/conventions/hook-telemetry/examples/block-no-verify.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "timestamp": "2026-07-10T14:26:55Z", + "hook": "block-no-verify", + "hook_event": "PreToolUse", + "status": "blocked", + "duration_ms": 9, + "data": { + "tool": "Bash", + "subject": "Bash:git", + "form": "no-verify" + } +} diff --git a/docs/conventions/hook-telemetry/examples/cli-flag-verify.json b/docs/conventions/hook-telemetry/examples/cli-flag-verify.json new file mode 100644 index 000000000..736c6c1a5 --- /dev/null +++ b/docs/conventions/hook-telemetry/examples/cli-flag-verify.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "timestamp": "2026-07-10T14:25:12Z", + "hook": "cli-flag-verify", + "hook_event": "PostToolUse", + "status": "ok", + "duration_ms": 512, + "data": { + "tool": "", + "file": "docs/setup.md", + "findings": ["claude --max-turns"] + } +} diff --git a/docs/conventions/hook-telemetry/examples/hardcoded-path-check.json b/docs/conventions/hook-telemetry/examples/hardcoded-path-check.json new file mode 100644 index 000000000..372617468 --- /dev/null +++ b/docs/conventions/hook-telemetry/examples/hardcoded-path-check.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "timestamp": "2026-07-10T14:23:41Z", + "hook": "hardcoded-path-check", + "hook_event": "PreToolUse", + "status": "blocked", + "duration_ms": 74, + "data": { + "tool": "Edit", + "file": "scripts/deploy.sh", + "violations": ["Linux user path detected"] + } +} diff --git a/docs/conventions/hook-telemetry/examples/secret-pattern-detection.json b/docs/conventions/hook-telemetry/examples/secret-pattern-detection.json new file mode 100644 index 000000000..aa35ffeb4 --- /dev/null +++ b/docs/conventions/hook-telemetry/examples/secret-pattern-detection.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "timestamp": "2026-07-10T14:22:07Z", + "hook": "secret-pattern-detection", + "hook_event": "PreToolUse", + "status": "blocked", + "duration_ms": 260, + "data": { + "tool": "Write", + "file": "src/config.env", + "violations": ["AWS Access Key", "GitHub PAT"] + } +} diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json new file mode 100644 index 000000000..3fbeb68f3 --- /dev/null +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "guardrails", + "version": "0.1.0", + "description": "Four PreToolUse safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, and (advisory) hallucinated CLI flags — each independently toggleable.", + "author": { + "name": "Melodic Software", + "email": "info@melodicsoftware.com" + }, + "keywords": ["guard", "security", "secrets", "hooks", "pretooluse", "git", "hardcoded-paths", "cli-flags"] +} diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md new file mode 100644 index 000000000..8edf88e17 --- /dev/null +++ b/plugins/guardrails/README.md @@ -0,0 +1,109 @@ +# guardrails + +A Claude Code plugin bundling four **PreToolUse 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. + +## The guards + +| Guard | Event / matcher | Behavior | What it catches | +|-------|-----------------|----------|-----------------| +| **secret-pattern-detection** | PreToolUse · Write \| Edit \| NotebookEdit | **Blocks** (exit 2) | High-confidence secret/credential patterns (AWS/GitHub/GitLab/Slack/Stripe/OpenAI keys, PEM private keys) in new file content. | +| **hardcoded-path-check** | PreToolUse · Write \| Edit \| NotebookEdit | **Blocks** (exit 2) | Hardcoded machine-specific paths — Windows drive-letter homes, macOS/Linux user homes, machine-specific repo checkout roots. | +| **block-no-verify** | PreToolUse · Bash | **Blocks** (exit 2) | Git hook-bypass attempts on `git commit` / `git push`: `--no-verify` / `-n`, `core.hooksPath=` assignment, and `LEFTHOOK=0` / `LEFTHOOK_*=false` env-var prefixes (including inside compound `cd … && …` commands). | +| **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. | + +The three blocking guards feed their stderr message back to Claude as +actionable fix guidance. The advisory guard surfaces its findings the same way +but always allows the edit. + +### Scope notes + +- **Hook-manager coverage.** `block-no-verify` recognizes the **lefthook** + env-var disable prefix (`LEFTHOOK=0` / `LEFTHOOK_*=false`). Other managers' + disable env vars (husky, pre-commit, …) are **not** matched — but the + manager-agnostic `--no-verify` / `-n` and `core.hooksPath=` checks catch those + bypasses regardless of which manager runs the hooks. +- **Argv-grammar-faithful matching (and its residual).** `block-no-verify` + parses the command the way the shell builds argv — segmenting on unquoted + operators and tokenizing each segment honoring `'…'`, `"…"`, `$'…'` (ANSI-C), + and backslash escapes. It detects literal `git commit`/`git push` + `--no-verify` / `-n` / `core.hooksPath=` across quoting, escaping, wrappers + (`env -i git …`, `nice git …`, `sudo -u x git …`), and git global options + (`git -C commit …`). A `--no-verify` inside a quoted `-m` value stays a + message, not a flag. It does **not** evaluate shell variable / command + substitution (`$VAR`, `$(…)`, `$IFS`) — a determined author can construct an + expansion-based bypass. **This is a friction guard against accidental/casual + bypass, not a sandbox.** (A command longer than 16 KB is not parsed and is + blocked fail-closed.) + +## Per-hook kill switches + +Each guard is toggled by its own env var (default **on**; set to `false` for a +clean no-op). This per-hook control is the bundle's core contract — disable one +guard without touching the others. + +| Guard | Kill switch | +|-------|-------------| +| secret-pattern-detection | `HOOK_SECRET_PATTERN_DETECTION_ENABLED` | +| hardcoded-path-check | `HOOK_HARDCODED_PATH_CHECK_ENABLED` | +| block-no-verify | `HOOK_BLOCK_NO_VERIFY_ENABLED` | +| cli-flag-verify | `HOOK_CLI_FLAG_VERIFY_ENABLED` | + +Set them in your settings `env` block: + +```json +{ "env": { "HOOK_HARDCODED_PATH_CHECK_ENABLED": "false" } } +``` + +## Consumer seams + +The guards scope and tune themselves to **your** repository — they ship no +repo-specific policy of their own: + +- **Project scoping.** `secret-pattern-detection` and `hardcoded-path-check` + only police files under `$CLAUDE_PROJECT_DIR`; a write into a sibling repo is + that repo's concern. Secret scanning fails **closed** — if the project root + cannot be resolved, it scans anyway. +- **Gitignore is the allowlist.** `hardcoded-path-check` skips any file + `git check-ignore` matches against your `$CLAUDE_PROJECT_DIR` — put + machine-local files (`settings.local.json`, `.venv/`, …) in your + `.gitignore` and they are exempt automatically. +- **Secret allowlist.** A generic built-in allowlist exempts dependency caches + (`.venv/`, `node_modules/`), `.env.example` / `.sample` / `.template` + placeholders, `tests/fixtures` / `tests/testdata` trees, `settings.local.json`, + `CLAUDE.local.md`, and hook scripts. +- **CLI-flag tuning.** `cli-flag-verify` checks a default binary set + (`claude gh dotnet docker npm kubectl terraform az aws`); override with + `HOOK_CLI_FLAG_VERIFY_BINS=bin1,bin2,…` and skip specific binaries with + `HOOK_CLI_FLAG_VERIFY_SKIP_BINS=bin1,bin2`. + +## Telemetry (opt-in) + +Every guard emits one structured [hook-telemetry](../../docs/conventions/hook-telemetry/README.md) +envelope per run to whatever `HOOK_TELEMETRY_SINK` names — carrying `status` +(`blocked` on a guard block, `ok` otherwise), `duration_ms`, and a privacy-safe +`data` payload (category **labels** only — never a secret value, matched path, +or full command). Unset `HOOK_TELEMETRY_SINK` → no-op; the guards behave exactly +as before. + +## Requirements + +- **bash 5.0+** and **jq** — the guards' runtime. Without **jq**, each guard + fails **open** (disabled) and prints a one-line stderr notice — never a silent + disable. +- On Windows, **Git Bash** (the hooks run via Git Bash's bash). +- `cli-flag-verify` runs ` --help` for the binaries it scans; findings + require those binaries on PATH (missing binaries are skipped, never flagged). + +## Install + +```shell +/plugin marketplace add melodic-software/claude-code-plugins +/plugin install guardrails@melodic-software +``` + +## License + +[MIT](../../LICENSE). diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh new file mode 100755 index 000000000..42f26ed52 --- /dev/null +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -0,0 +1,488 @@ +#!/usr/bin/env bash +# PreToolUse hook: block git hook-bypass attempts on git commit and git push. +# Triggered on Bash tool calls. +# +# Catches three bypass surfaces on a real `git commit` / `git push`: +# 1. --no-verify / -n on git commit (skips pre-commit + commit-msg hooks) +# and --no-verify on git push (skips pre-push hook) +# 2. core.hooksPath assignment on git commit/push (disables all git hooks) +# 3. hook-manager env-var prefix (LEFTHOOK=0 / LEFTHOOK_*=0|false) on git +# commit/push (disables the hook manager for one invocation) +# +# Detection is ARGV-GRAMMAR-FAITHFUL: the command is parsed the way the shell +# builds argv — top-level segments split on unquoted control operators, each +# tokenized into argv words honoring '…', "…", $'…' (ANSI-C), and backslash +# escapes — then a real git executable (basename `git`, case/.exe-folded on +# Windows) is found at the segment's command position — after leading env-var +# assignments and known wrappers (`env -i git …`, `nice git …`, `sudo -u x git …` +# are transparent) — its subcommand resolved +# past git global options (including arg-consuming ones like `-C `), and +# the bypass tokens matched on the parsed argv. +# +# SCOPE (documented residual): this is a static matcher over the literal +# command string. It does NOT evaluate shell variable / command substitution +# ($VAR, $(…), $IFS) — a determined author can construct an expansion-based +# bypass. It is a friction guard against accidental/casual bypass, not a +# sandbox. The ONLY supported deliberate bypass is the kill switch +# (HOOK_BLOCK_NO_VERIFY_ENABLED=false). +# +# BLOCKING: exits 2 on any detected bypass form. + +set -uo pipefail + +# shellcheck source=hook-utils.sh +source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" + +hook::check_enabled "BLOCK_NO_VERIFY" + +# 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-no-verify: jq not found on PATH — guard disabled (install jq to enable)." >&2 + exit 0 +fi + +# Read inherited fd0 directly (bare cat) — NEVER `/dev/null | tr -d '\r') +[[ -n "$COMMAND" ]] || exit 0 + +# Above this length the command is not parsed — a pathologically long command is +# assumed to be obfuscation and blocked FAIL-CLOSED (generous cap; real git +# commands are well under it). The linear parser keeps normal commands cheap. +MAX_COMMAND_LEN=16384 + +# Privacy-safe telemetry subject: `Bash:` with leading `sudo` / +# env-assignment prefixes stripped and the token basenamed. Never the full +# command. +bash_subject() { + local cmd="$1" tok + tok="${cmd%%[[:space:]]*}" + while [[ "$tok" == "sudo" || "$tok" == *=* ]] \ + && [[ -n "$cmd" && "$cmd" == *[[:space:]]* ]]; do + cmd="${cmd#*[[:space:]]}" + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + tok="${cmd%%[[:space:]]*}" + done + printf 'Bash:%s' "${tok##*/}" +} + +SUBJECT=$(bash_subject "$COMMAND") + +# Emit one telemetry envelope: $1 status, $2 form ("" when not blocked). Gated +# on the high-res start stamp and the opt-in sink, so the unwired default path +# spawns no telemetry-only subprocess. +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-no-verify" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" +} + +block() { + local form="$1" msg1="$2" msg2="$3" + echo "$msg1" >&2 + echo "$msg2" >&2 + emit_tel "blocked" "$form" + exit 2 +} + +# Does an argv word name the git executable? Basename compared exactly on POSIX; +# on Windows/MSYS also case-folded and `.exe`-stripped (mirrors the OS-gate in +# hook-utils normalize_path) so `GIT` / `git.exe` are caught there but a +# case-variant stays distinct on a case-sensitive POSIX filesystem. +is_git_bin() { + local b="${1##*/}" + b="${b##*\\}" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) + local lc="${b,,}" + lc="${lc%.exe}" + [[ "$lc" == "git" ]] + ;; + *) [[ "$b" == "git" ]] ;; + esac +} + +# Decode an ANSI-C `$'…'` body to its literal bytes (\xHH, \NNN octal, \uHHHH, +# \n, \\, …). %-escaped so the body can never act as a printf format specifier; +# `--` guards a body that begins with `-`. Errors are swallowed (fail-open on a +# malformed body — the raw text still flows through the caller unchanged). +ansi_c_decode() { + local b="${1//%/%%}" + # shellcheck disable=SC2059 # the body IS the format — that is how ANSI-C escapes decode; %-escaped above so it cannot inject a specifier + printf -- "$b" 2>/dev/null +} + +# Locate a real `git` executable at the segment's command position (after +# env-var prefixes and known wrappers), or return 1 when absent. Results go in +# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller +# must match on the rewritten words, so the index alone is not enough. +# RESOLVED_GI — index of git in RESOLVED_WORDS +# RESOLVED_WORDS — the (possibly rewritten) segment argv +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +resolve_git_index() { + RESOLVED_WORDS=("$@") + RESOLVED_GI=-1 + local -n w=RESOLVED_WORDS + local n=${#w[@]} i=0 tok + + while ((i < n)); do + tok="${w[i]}" + if [[ "$tok" == *=* ]]; then + ((i++)) + continue + fi + + case "${tok##*/}" in + env) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + # -S/--split-string re-splits its operand into argv (GNU env), so a + # quoted 'git commit --no-verify' would otherwise hide from the + # resolver as one non-git word. Splice the split words back into the + # scan and restart at the command position. + -S | --split-string) + local sval="" + ((i + 1 < n)) && sval="${w[i + 1]}" + local -a sw=() + read -r -a sw <<<"$sval" + w=("${sw[@]}" "${w[@]:i+2}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + local -a sw=() + read -r -a sw <<<"$sval" + w=("${sw[@]}" "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + done + continue + ;; + nice | nohup) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -n | --adjustment) ((i += 2)) ;; + --adjustment=*) ((i++)) ;; + -*) ((i++)) ;; + *) break ;; + esac + done + if ((i < n)) && [[ "${w[i]}" =~ ^-?[0-9]+$ ]]; then + ((i++)) + fi + continue + ;; + sudo) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -u | -g | -h | -p | -C | -D | -R | -T | --user | --group | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + done + continue + ;; + timeout) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -s | --signal | -k | --kill-after) ((i += 2)) ;; + --preserve-status | --foreground | --verbose) ((i++)) ;; + -*) ((i++)) ;; + *) break ;; + esac + done + if ((i < n)) && [[ "${w[i]}" =~ ^[0-9]+([.][0-9]+)?(s|m|h|d)?$ ]]; then + ((i++)) + fi + continue + ;; + # eval concatenates and re-executes its arguments, so for the unquoted + # form (`eval git commit ...`) scanning the following words is exact. + command | exec | builtin | eval | !) + ((i++)) + continue + ;; + time) + ((i++)) + if ((i < n)) && [[ "${w[i]}" == "-p" ]]; then + ((i++)) + fi + continue + ;; + if | while | until | for | case | select | coproc | '{' | '}') + ((i++)) + continue + ;; + *) + if is_git_bin "$tok"; then + RESOLVED_GI=$i + return 0 + fi + return 1 + ;; + esac + done + return 1 +} + +# Inspect one already-tokenized segment (its argv words passed as "$@"). Blocks +# when the segment is a real `git commit`/`git push` carrying a bypass token. +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +check_segment() { + local -a w=("$@") + local nseg=${#w[@]} gi j k x lc ch rest sub sub_idx gw + + resolve_git_index "${w[@]}" || return 0 + gi=$RESOLVED_GI + # env -S splicing may have rewritten the argv — match on the resolved words. + w=("${RESOLVED_WORDS[@]}") + nseg=${#w[@]} + + # Resolve the subcommand: walk words after the git executable, skipping git + # global options. The listed options consume the FOLLOWING word as their + # value (two-word form); their =-forms and every other option are single + # words handled by the generic `-*` skip. core.hooksPath is checked only on + # git config arguments (-c/--config/--config-env), not commit messages or + # pathspecs. + sub="" + sub_idx=-1 + j=$((gi + 1)) + while ((j < nseg)); do + gw="${w[j]}" + case "$gw" in + -c | --config | --config-env) + if ((j + 1 < nseg)); then + lc="${w[j + 1],,}" + [[ "$lc" == *core.hookspath=* ]] && block "hooksPath" \ + "BLOCKED: core.hooksPath assignment is not allowed with git commit/push." \ + "Fix the hook failure instead of bypassing git hooks." + fi + ((j += 2)) + ;; + --config=*|--config-env=*) + lc="${gw#*=}" + lc="${lc,,}" + [[ "$lc" == *core.hookspath=* ]] && block "hooksPath" \ + "BLOCKED: core.hooksPath assignment is not allowed with git commit/push." \ + "Fix the hook failure instead of bypassing git hooks." + ((j++)) + ;; + -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) + ((j += 2)) + ;; + -*) + ((j++)) + ;; + *) + sub="$gw" + sub_idx=$j + break + ;; + esac + done + [[ "$sub" == "commit" || "$sub" == "push" ]] || return 0 + + # Form 2: hook-manager env-var prefix (commit OR push) — only leading env + # assignments before the git executable (not commit messages or pathspecs). + for ((k = 0; k < gi; k++)); do + lc="${w[k],,}" + [[ "$lc" =~ ^lefthook[_a-z0-9]*=(0|false)$ ]] && block "hook-manager-env" \ + "BLOCKED: hook-manager env-var bypass is not allowed with git commit/push." \ + "Fix the hook lane failure instead of bypassing." + done + + # Form 1: --no-verify / -n (commit or push) — words after the subcommand, + # skipping values consumed by commit/push options (e.g. -m message text). + # In a short-option bundle, `n` counts only when it precedes the first + # argument-taking short (m/F/c/C/t/u/S/G) — so `-nm msg` blocks but `-mn` + # (m takes value "n") does not. + if [[ "$sub" == "commit" || "$sub" == "push" ]]; then + k=$((sub_idx + 1)) + while ((k < nseg)); do + x="${w[k]}" + case "$x" in + -m | --message | -F | --file | -t | --template | -c | -C | --author | --date) + ((k += 2)) + continue + ;; + --message=* | --file=* | --template=* | --author=* | --date=*) + ((k++)) + continue + ;; + *) ;; + esac + [[ "$x" == "--no-verify" ]] && block "no-verify" \ + "BLOCKED: --no-verify / -n flags are not allowed with git $sub." \ + "Fix the issues that caused the hook failure instead of bypassing." + if [[ "$sub" == "commit" && "$x" =~ ^-[A-Za-z]+$ ]]; then + rest="${x#-}" + for ((ch = 0; ch < ${#rest}; ch++)); do + case "${rest:ch:1}" in + n) block "no-verify" \ + "BLOCKED: --no-verify / -n flags are not allowed with git commit." \ + "Fix the issues that caused the hook failure instead of bypassing." ;; + m | F | c | C | t | u | S | G) + if ((ch + 1 < ${#rest})); then + ((k++)) + elif ((k + 1 < nseg)); then + ((k += 2)) + else + ((k++)) + fi + continue 2 + ;; + *) ;; + esac + done + fi + ((k++)) + done + fi + + return 0 +} + +# Single linear pass: read the command into a char array once (O(n)), then walk +# it splitting top-level segments on UNQUOTED control operators and tokenizing +# each segment into argv words honoring '…', "…", $'…', and backslash escapes +# (including backslash-newline continuation). Each completed segment is checked +# as it closes, so no full segment list is retained. +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +parse_and_check() { + local -a chars=() + local c nx + while IFS= read -rN1 c; do chars+=("$c"); done < <(printf '%s' "$COMMAND") + local n=${#chars[@]} i + local word="" have=0 + local -a seg=() + + for ((i = 0; i < n; i++)); do + c="${chars[i]}" + case "$c" in + "'") + ((i++)) + while ((i < n)) && [[ "${chars[i]}" != "'" ]]; do + word+="${chars[i]}" + ((i++)) + done + have=1 + ;; + '"') + ((i++)) + while ((i < n)) && [[ "${chars[i]}" != '"' ]]; do + if [[ "${chars[i]}" == '\' ]] && ((i + 1 < n)); then + nx="${chars[i + 1]}" + case "$nx" in + '"' | '\' | '$' | '`') + word+="$nx" + ((i += 2)) + continue + ;; + $'\n') + ((i += 2)) + continue + ;; + *) ;; + esac + fi + word+="${chars[i]}" + ((i++)) + done + have=1 + ;; + '$') + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == "'" ]]; then + i=$((i + 2)) + local body="" + while ((i < n)) && [[ "${chars[i]}" != "'" ]]; do + if [[ "${chars[i]}" == '\' ]] && ((i + 1 < n)); then + body+="${chars[i]}${chars[i + 1]}" + ((i += 2)) + continue + fi + body+="${chars[i]}" + ((i++)) + done + word+="$(ansi_c_decode "$body")" + have=1 + else + word+="$c" + have=1 + fi + ;; + '\') + if ((i + 1 < n)); then + nx="${chars[i + 1]}" + if [[ "$nx" == $'\n' ]]; then + ((i++)) + else + word+="$nx" + ((i++)) + have=1 + fi + else + have=1 + fi + ;; + ' ' | $'\t') + if ((have)); then + seg+=("$word") + word="" + have=0 + fi + ;; + ';' | '&' | '|' | '(' | ')' | '`' | $'\n') + if ((have)); then + seg+=("$word") + word="" + have=0 + fi + if ((${#seg[@]})); then + check_segment "${seg[@]}" + seg=() + fi + ;; + *) + word+="$c" + have=1 + ;; + esac + done + if ((have)); then seg+=("$word"); fi + if ((${#seg[@]})); then check_segment "${seg[@]}"; fi +} + +if ((${#COMMAND} > MAX_COMMAND_LEN)); then + block "too-long" \ + "BLOCKED: command too long to parse safely (> $MAX_COMMAND_LEN chars)." \ + "Shorten the command, or set HOOK_BLOCK_NO_VERIFY_ENABLED=false to bypass." +fi + +parse_and_check + +emit_tel "ok" "" +exit 0 diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh new file mode 100755 index 000000000..d2ebbc8d8 --- /dev/null +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Contract test for block-no-verify.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-no-verify.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# shellcheck source=guardrails-test-helpers.sh +source "$HOOK_DIR/guardrails-test-helpers.sh" + +# run