From 7675393e9f0380ba198024741ccfebddde1ca7ca Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:40:00 +0000 Subject: [PATCH 1/9] fix(repo-hygiene): scope clean's destructive guard to an open run and match parsed words Co-Authored-By: Claude Sonnet 5.5 --- ...n-guard-run-marker-quoted-args-7333a1f4.md | 8 + plugins/repo-hygiene/README.md | 9 +- plugins/repo-hygiene/skills/clean/SKILL.md | 13 +- .../skills/clean/context/git-tree-reset.md | 2 +- .../skills/clean/context/run-marker.md | 35 + .../skills/clean/scripts/clean-run-marker.sh | 93 + .../skills/clean/scripts/destructive-guard.sh | 174 +- .../clean/scripts/destructive-guard.test.sh | 150 +- .../skills/clean/scripts/hook-utils.sh | 5048 +++++++++++++++++ .../skills/clean/scripts/lib/run-marker.sh | 56 + scripts/shared-copies.txt | 1 + 11 files changed, 5571 insertions(+), 18 deletions(-) create mode 100644 .changes/repo-hygiene/fix-clean-guard-run-marker-quoted-args-7333a1f4.md create mode 100644 plugins/repo-hygiene/skills/clean/context/run-marker.md create mode 100644 plugins/repo-hygiene/skills/clean/scripts/clean-run-marker.sh create mode 100644 plugins/repo-hygiene/skills/clean/scripts/hook-utils.sh create mode 100644 plugins/repo-hygiene/skills/clean/scripts/lib/run-marker.sh diff --git a/.changes/repo-hygiene/fix-clean-guard-run-marker-quoted-args-7333a1f4.md b/.changes/repo-hygiene/fix-clean-guard-run-marker-quoted-args-7333a1f4.md new file mode 100644 index 0000000000..8da0a6e7cd --- /dev/null +++ b/.changes/repo-hygiene/fix-clean-guard-run-marker-quoted-args-7333a1f4.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Fixed + +- The clean skill's destructive guard now gates only while a mutating clean run is open, tracked by a per-session run marker that `scan` and `scan-batch` never write, so it no longer blocks unrelated commands for the rest of the session. +- On the Bash tool the guard matches each command's parsed words instead of the raw text, so destructive text inside a quoted argument (an issue comment body, a commit message) no longer blocks the command. diff --git a/plugins/repo-hygiene/README.md b/plugins/repo-hygiene/README.md index a39fe17305..5e69be03a2 100644 --- a/plugins/repo-hygiene/README.md +++ b/plugins/repo-hygiene/README.md @@ -78,15 +78,18 @@ ghq list -p | bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/git-branch-audit.s - **Any git-tracked file is off-limits** to selective deletion; a tracked file deleted by reparse-point (junction/symlink) traversal during a `tree` clean is auto-restored from the index. -- **Session-scoped destructive guard.** While the skill is active, a PreToolUse - hook blocks bare `rm -rf`, `git clean -f*`, `git reset --hard`, +- **Session-scoped destructive guard.** While a mutating clean run is open (a + per-session run marker under the plugin data directory, written by the + mutating tiers and never by `scan` or `scan-batch`, cleared when the run ends, + with an idle timeout as a fallback), a PreToolUse hook blocks bare `rm -rf`, `git clean -f*`, `git reset --hard`, `git checkout --`, recursive `Remove-Item`, bare `git branch -D`/`-d`/`--delete`, `git push --delete` and `git push origin :ref` (also `+:ref`), the clean scripts when the command contains `--apply`, and `git worktree remove` with a force flag. A dry-run (including a dry-run push) is not blocked. The confirmed command runs only through the skill's own gate, and the ack-prefixed spelling is the documented way to run a bare `git branch -D` during a clean session. The guard - is a best-effort net over command text, not a security boundary. The skill deletes local + is a best-effort net over command words, not a security boundary; on the Bash tool destructive + text inside one quoted argument does not match. The skill deletes local branches with `git-branch-delete.sh` after the confirmation gate, which uses `git update-ref -d` and so does not go through the branch patterns. Kill switch: the `clean_destructive_guard_enabled` userConfig option set to `false` (`/plugin configure repo-hygiene@`, or diff --git a/plugins/repo-hygiene/skills/clean/SKILL.md b/plugins/repo-hygiene/skills/clean/SKILL.md index d2f6cc088e..7c14011ea0 100644 --- a/plugins/repo-hygiene/skills/clean/SKILL.md +++ b/plugins/repo-hygiene/skills/clean/SKILL.md @@ -17,7 +17,7 @@ allowed-tools: # matches these scripts when the command text contains `--apply`, plus # `git worktree remove` with a force flag. A dry-run of the same scripts, or # of a push, does not match. It does not match obfuscated spellings (eval, - # `bash -c`, aliases). The guard is a best-effort net over command text, not + # a `bash -c` behind sudo, aliases). The guard is a best-effort net over command words, not # a security boundary. # git-branch-audit.sh writes one TipCapture file under the git common dir # (.git/repo-hygiene/branch-tips/) per run, and its landed-proof squash step @@ -40,7 +40,9 @@ hooks: # Exec form. `command` is `node`. exec-bash.mjs finds Git Bash and # never System32\bash.exe, then runs destructive-guard.sh. `args` # names only ${CLAUDE_PLUGIN_ROOT}, by design; whether a - # skill-frontmatter hook receives ${CLAUDE_PLUGIN_DATA} is untested. + # skill-frontmatter hook receives ${CLAUDE_PLUGIN_DATA} is untested. The + # guard reads it from its environment to find the run marker and stays + # armed when it is empty. # Pointer: https://code.claude.com/docs/en/hooks#exec-form-and-shell-form # and https://code.claude.com/docs/en/hooks#reference-scripts-by-path. # As of: 2026-10-11. @@ -137,7 +139,7 @@ Protected-path enforcement gates `scan`, `caches`, `build`, `git`, AND `tree` (` `tree` requires explicit confirmation and is never auto-invoked. Any file tracked by git is reset via `git reset --hard`, not selective deletion, and any tracked file deleted by reparse-point traversal (junction/symlink into a tracked dir) is auto-restored. -**Session-scoped destructive guard (frontmatter hook).** While this skill is active, a PreToolUse hook (`scripts/destructive-guard.sh`) inspects Bash **and** PowerShell tool calls and blocks destructive command shapes (`rm -rf`, `git clean -f*`, `git reset --hard`, `git checkout --`, `git stash drop`/`clear`, recursive `Remove-Item`, bare `git branch -D`/`-d`/`--delete`, `git push --delete`, `git push origin :ref` (also `+:ref`)). It also blocks this skill's mutating scripts (`clean-caches.sh`, `clean-build.sh`, `git-prune.sh`, `git-tree-reset.sh`, `git-tree-reset-batch.sh`, `remove-path.sh`, `clean-batch.sh`) when the command contains `--apply`, and `git worktree remove` with a force flag. A dry-run of those scripts, or of a push, is not blocked. It is a best-effort net over command text, not a security boundary: obfuscated spellings (`eval`, `bash -c`, aliases) get past it. After the [confirmation gate](#confirmation-gate) passes, re-issue the confirmed command with the acknowledgement prefix for the tool you are using: `CLEAN_GUARD_ACK=1 ` on the Bash tool, `$env:CLEAN_GUARD_ACK=1; ` on the PowerShell tool. Each spelling is a real assignment only in its own shell, so the guard accepts it only there, and only as the first statement of the command (not in a comment, a string, or after the destructive command). The gating is per tool and default-deny: any other tool name, including a missing one, gets no acknowledgement path at all, so the block stands and no prefix lifts it. Never add the prefix without the user's explicit confirmation in this session. Kill switch: the `clean_destructive_guard_enabled` userConfig option set to `false` (`/plugin configure repo-hygiene@`). The prefix satisfies only this guard; Claude Code's permission rules and auto-mode classifier still apply ([the host permission layer above the ack](reference/invocation-forms.md#the-host-permission-layer-above-the-ack)). +**Session-scoped destructive guard (frontmatter hook).** Skill hooks last the rest of the session, so the registration alone cannot end the guard with the run. A per-session run marker scopes it: [run marker](context/run-marker.md) is opened when a mutating tier starts (never for `scan` or `scan-batch`) and cleared when the run reaches its final outcome, and with no live marker the guard allows every command. While a run is open, a PreToolUse hook (`scripts/destructive-guard.sh`) inspects Bash **and** PowerShell tool calls and blocks destructive command shapes (`rm -rf`, `git clean -f*`, `git reset --hard`, `git checkout --`, `git stash drop`/`clear`, recursive `Remove-Item`, bare `git branch -D`/`-d`/`--delete`, `git push --delete`, `git push origin :ref` (also `+:ref`)). It also blocks this skill's mutating scripts (`clean-caches.sh`, `clean-build.sh`, `git-prune.sh`, `git-tree-reset.sh`, `git-tree-reset-batch.sh`, `remove-path.sh`, `clean-batch.sh`) when the command contains `--apply`, and `git worktree remove` with a force flag. A dry-run of those scripts, or of a push, is not blocked. On the Bash tool it matches the parsed words of each command, so destructive text inside one quoted argument (an issue comment body, a commit message) does not match; the PowerShell tool keeps raw-text matching. It is a best-effort net over command words, not a security boundary: obfuscated spellings (`eval`, a `bash -c` stacked behind `sudo`, aliases) get past it. After the [confirmation gate](#confirmation-gate) passes, re-issue the confirmed command with the acknowledgement prefix for the tool you are using: `CLEAN_GUARD_ACK=1 ` on the Bash tool, `$env:CLEAN_GUARD_ACK=1; ` on the PowerShell tool. Each spelling is a real assignment only in its own shell, so the guard accepts it only there, and only as the first statement of the command (not in a comment, a string, or after the destructive command). The gating is per tool and default-deny: any other tool name, including a missing one, gets no acknowledgement path at all, so the block stands and no prefix lifts it. Never add the prefix without the user's explicit confirmation in this session. Kill switch: the `clean_destructive_guard_enabled` userConfig option set to `false` (`/plugin configure repo-hygiene@`). The prefix satisfies only this guard; Claude Code's permission rules and auto-mode classifier still apply ([the host permission layer above the ack](reference/invocation-forms.md#the-host-permission-layer-above-the-ack)). **Guard coverage for branch deletion.** The guard matches bare `git branch -D`/`-d`/`--delete`, `git push --delete`, `git push -d` and `git push origin :ref` (also `+:ref`); a push with `--dry-run`/`-n` is allowed. The ack prefix lifts these blocks like the others and is the documented way to run a bare `git branch -D` during a clean session; a spelling the patterns do not parse (a global option with a separate value such as `--work-tree `, an alias) is not blocked. This skill deletes local branches with `git-branch-delete.sh` after the confirmation gate; the script deletes with `git update-ref -d`, so the confirmed path does not go through these patterns. No script or instruction this skill runs (excluding `*.test.sh` fixtures) runs `git push --delete`. @@ -163,6 +165,8 @@ Per-tier targets: [reference/cleanup-config.md](reference/cleanup-config.md). Sc Run `resolve-clean-action.sh`. If `Action: menu`, show table + ask. Otherwise dispatch to the matching § below. **Never `--apply` on first invocation.** +For any resolved action other than `scan` and `scan-batch`, open the run marker before the first dry-run or `--apply` and clear it when the run reaches its final outcome (done, declined or aborted): [context/run-marker.md](context/run-marker.md). This session's id is `${CLAUDE_SESSION_ID}`; the plugin data directory is `${CLAUDE_PLUGIN_DATA}`. + Resolve no repo root here. This step is reachable from anywhere, including outside a repository, and a bare `git rev-parse --show-toplevel` on that path exits non-zero and takes the menu down with it. The tiers that need a root resolve it themselves, guarded, in §§ 4 to 6 below; the bundled scripts each resolve their own and print `Error: not a git repository` rather than failing. ### 1. Scan (`scan`) @@ -248,7 +252,8 @@ Repo sources: `--repo` (repeatable; a shell glob expands to these), `--repos-fro ## Spoke paths The `context/` files write this skill's directory as ``, which is `${CLAUDE_SKILL_DIR}`, -and the plugin data directory as ``, which is `${CLAUDE_PLUGIN_DATA}`. Put those paths +the plugin data directory as ``, which is `${CLAUDE_PLUGIN_DATA}`, and the session id as +``, which is `${CLAUDE_SESSION_ID}`. Put those values in place of the placeholders before running a command. Those files arrive through the Read tool as plain bytes, so a `${…}` token in them would reach the Bash tool unsubstituted. diff --git a/plugins/repo-hygiene/skills/clean/context/git-tree-reset.md b/plugins/repo-hygiene/skills/clean/context/git-tree-reset.md index 9e0db9ec68..72b3ccaeb6 100644 --- a/plugins/repo-hygiene/skills/clean/context/git-tree-reset.md +++ b/plugins/repo-hygiene/skills/clean/context/git-tree-reset.md @@ -59,4 +59,4 @@ Skill data (`.claude/skills/*/data/`) is preserved unconditionally. No flag remo ## Hook interaction -The session-scoped destructive guard (`scripts/destructive-guard.sh`) blocks bare `git clean -f` / `git reset --hard` while this skill is active, and blocks `git-tree-reset.sh --apply` until the command starts with the acknowledgement prefix (`CLEAN_GUARD_ACK=1 bash git-tree-reset.sh … --apply` on Bash, `$env:CLEAN_GUARD_ACK=1; …` on PowerShell), added only after the confirmation gate. A dry-run is not blocked. Run the reset through the wrapper, not inline git commands: it runs those as subprocesses. +The session-scoped destructive guard (`scripts/destructive-guard.sh`) blocks bare `git clean -f` / `git reset --hard` while a clean run is open (the run marker), and blocks `git-tree-reset.sh --apply` until the command starts with the acknowledgement prefix (`CLEAN_GUARD_ACK=1 bash git-tree-reset.sh … --apply` on Bash, `$env:CLEAN_GUARD_ACK=1; …` on PowerShell), added only after the confirmation gate. A dry-run is not blocked. Run the reset through the wrapper, not inline git commands: it runs those as subprocesses. diff --git a/plugins/repo-hygiene/skills/clean/context/run-marker.md b/plugins/repo-hygiene/skills/clean/context/run-marker.md new file mode 100644 index 0000000000..713e190fc5 --- /dev/null +++ b/plugins/repo-hygiene/skills/clean/context/run-marker.md @@ -0,0 +1,35 @@ +# Run marker + +The destructive guard gates only while a mutating clean run is open in the session. The marker is a +file, `/clean-run/.open`, holding the epoch second of its last write. + +## Open + +Right after the action resolves, for every action except `scan` and `scan-batch`, before the first +dry-run or `--apply`: + +```bash +bash /scripts/clean-run-marker.sh open --tier --session --data-dir +``` + +`scan` and `scan-batch` exit 0 with `Marker: not armed` and write nothing. Any other failure (exit 2: +the data directory or session id did not resolve) means the guard cannot be scoped to this run: stop +and tell the user rather than continuing without it. + +## Clear + +When the run reaches its final outcome (applied, declined at the confirmation gate, or aborted): + +```bash +bash /scripts/clean-run-marker.sh close --session --data-dir +``` + +It succeeds when the marker is already gone. Until it runs, the guard keeps gating every Bash and +PowerShell call in the session. + +## Idle timeout + +The guard refreshes a live marker on each call it gates. A marker idle for an hour is deleted by the +guard's next call, which then allows the command. This covers a run that ended without the clear step. +The guard stays armed, ignoring the marker, when `CLAUDE_PLUGIN_DATA` is empty in the hook process or +the hook input carries no usable `session_id`. diff --git a/plugins/repo-hygiene/skills/clean/scripts/clean-run-marker.sh b/plugins/repo-hygiene/skills/clean/scripts/clean-run-marker.sh new file mode 100644 index 0000000000..cca25d17b6 --- /dev/null +++ b/plugins/repo-hygiene/skills/clean/scripts/clean-run-marker.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Opens, closes or reports the per-session run-open marker that scopes the clean +# skill's destructive guard (destructive-guard.sh) to a mutating clean run. +# +# Usage: +# clean-run-marker.sh open --tier --session --data-dir +# clean-run-marker.sh close --session --data-dir +# clean-run-marker.sh --help +# +# open writes the marker for every tier except the read-only scan and +# scan-batch, which never arm the guard (it prints "not armed" and writes +# nothing). close removes it and succeeds when it is already gone. The marker +# lives under /clean-run/.open; the guard reads the same path from +# CLAUDE_PLUGIN_DATA and the hook input's session_id. +# +# Exit: 0 success; 2 usage error or an unusable --session / --data-dir. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/run-marker.sh +source "$SCRIPT_DIR/lib/run-marker.sh" + +usage() { + sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +ACTION="${1:-}" +case "$ACTION" in +open | close) shift ;; +--help | -h) + usage + exit 0 + ;; +*) + usage >&2 + exit 2 + ;; +esac + +TIER="" SESSION="" DATA_DIR="" +while [[ $# -gt 0 ]]; do + case "$1" in + --tier) + TIER="${2:-}" + shift 2 || shift + ;; + --session) + SESSION="${2:-}" + shift 2 || shift + ;; + --data-dir) + DATA_DIR="${2:-}" + shift 2 || shift + ;; + --help | -h) + usage + exit 0 + ;; + *) + echo "Error: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +if [[ "$ACTION" == open ]]; then + if [[ -z "$TIER" ]]; then + echo "Error: open needs --tier" >&2 + exit 2 + fi + case "$TIER" in + scan | scan-batch) + echo "Marker: not armed (read-only tier: $TIER)" + exit 0 + ;; + esac +fi + +if ! run_marker_path_to MARKER "$DATA_DIR" "$SESSION"; then + echo "Error: --data-dir and --session must both resolve (session ids use letters, digits, _ and -); the destructive guard cannot be scoped to this run." >&2 + exit 2 +fi + +if [[ "$ACTION" == open ]]; then + if ! run_marker_write "$MARKER"; then + echo "Error: could not write the run marker: $MARKER" >&2 + exit 2 + fi + echo "Marker: armed ($MARKER)" +else + rm -f "$MARKER" + echo "Marker: cleared" +fi diff --git a/plugins/repo-hygiene/skills/clean/scripts/destructive-guard.sh b/plugins/repo-hygiene/skills/clean/scripts/destructive-guard.sh index 7392e65cf9..3c4d054ce4 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/destructive-guard.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/destructive-guard.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Session-scoped PreToolUse guard active only while the clean skill is invoked -# (registered via SKILL.md frontmatter hooks — the on-demand-hook pattern). +# Session-scoped PreToolUse guard registered via SKILL.md frontmatter hooks, +# which last the rest of the session; a run-open marker scopes it to a clean run. # Blocks destructive Bash and PowerShell commands unless the invocation is # acknowledged with a CLEAN_GUARD_ACK=1 assignment at the very start of the # command, which the skill body adds ONLY after the dry-run -> user-confirmation @@ -18,12 +18,36 @@ # Exit codes: 0 = allow, 2 = block (stderr reason returned to Claude) # Threat model: this is a best-effort NET, not a security boundary. It matches -# command TEXT with regexes, so obfuscation (eval, $(), base64, `bash -c` -# wrappers, aliases) bypasses it by design — the goal is to catch the common +# command words, so obfuscation (eval, $(), base64, a `bash -c` stacked behind +# sudo, aliases) bypasses it by design — the goal is to catch the common # destructive spellings an agent might fumble and route them through the clean # skill's confirmation gate. The durable destructive-op boundary is the consumer's own # settings.json permissions.deny plus their git hooks, which apply independent -# of clean-session state. Coverage is whatever is_destructive() lists. +# of clean-session state. Coverage is whatever is_destructive() and +# argv_destructive() list. +# +# Lifetime. Claim: as of 2026-10-18 a hook registered in a skill's frontmatter +# stays active for the rest of the session once the skill has been invoked, so +# the hook registration alone cannot scope the guard to a clean run. The scope +# is a per-session run-open marker, /clean-run/.open, +# which clean-run-marker.sh writes when a mutating tier starts (never for scan or +# scan-batch) and removes when the run reaches its final outcome. With no marker +# for the hook input's session_id the guard allows the command. A marker idle for +# RUN_MARKER_IDLE_SECONDS (lib/run-marker.sh) is deleted and the command allowed; +# every gated call refreshes a live marker. An unresolved CLAUDE_PLUGIN_DATA or a +# missing or unusable session_id keeps the guard armed (fail closed), and so does +# the no-jq mode, which cannot read the session_id. Basis: the skill-frontmatter hook +# notes in https://code.claude.com/docs/en/hooks (the owner's decision on #7333 +# cites them) and the marker cases in destructive-guard.test.sh. Not verified in +# this change: that CLAUDE_PLUGIN_DATA reaches a skill-frontmatter hook process. +# Recheck: the hooks page changes how skill hooks end, or the first live run +# shows the guard armed with no marker. +# +# Precision. On the Bash tool is_destructive_bash() runs argv_destructive() over +# each segment's argv from hook::bash_parse_segments, so destructive text inside +# one quoted argument (a `gh issue comment --body '... git branch -D ...'`) is a +# single inert word and does not match. The PowerShell tool and the other tools +# keep raw-string matching in is_destructive(). # # Branch deletion. Claim: as of 2026-09-29 this net matches bare # `git branch -D`/`-d`/`--delete`, and `git push --delete`, `push -d` and @@ -48,6 +72,13 @@ set -uo pipefail +# Builtins only: the no-jq test runs this script with a PATH that lacks dirname. +_src="${BASH_SOURCE[0]}" +[[ "$_src" == */* ]] || _src="./$_src" +SCRIPT_DIR="$(cd "${_src%/*}" && pwd)" +# shellcheck source=lib/run-marker.sh +source "$SCRIPT_DIR/lib/run-marker.sh" + if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then sed -n '2,19p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 @@ -109,6 +140,112 @@ is_destructive() { grep -qE "git[[:space:]]+${gopt}reset[[:space:]]+--hard|git[[:space:]]+${gopt}checkout[[:space:]]+--[[:space:]]|git[[:space:]]+${gopt}stash[[:space:]]+(drop|clear)([[:space:]]|$)|Remove-Item[[:space:]].*-Recurse" <<<"$cmd" } +# argv_destructive ... : the same coverage as is_destructive(), over one +# parsed segment's argv. Every test is on a whole word, so destructive text that +# sits inside one quoted argument never matches. Returns 0 when destructive. +argv_destructive() { + local -a w=("$@") + local n=${#w[@]} i k j sub word base script=0 apply=0 + local -a rest + local recursive force dry del + for ((i = 0; i < n; i++)); do + word="${w[i]}" + base="${word##*/}" + [[ "$word" == "--apply" ]] && apply=1 + case "$base" in + clean-caches.sh | clean-build.sh | git-prune.sh | git-tree-reset-batch.sh | git-tree-reset.sh | remove-path.sh | clean-batch.sh) + script=1 + ;; + rm) + recursive=0 force=0 + for ((j = i + 1; j < n; j++)); do + [[ "${w[j]}" =~ ^-[a-zA-Z]*[rR] || "${w[j]}" =~ ^--recursive(=.*)?$ ]] && recursive=1 + [[ "${w[j]}" =~ ^-[a-zA-Z]*f || "${w[j]}" == --force ]] && force=1 + done + ((recursive && force)) && return 0 + ;; + Remove-Item) + for ((j = i + 1; j < n; j++)); do + [[ "${w[j]}" == -Recurse* ]] && return 0 + done + ;; + git) + # Skip git's global options, the way the raw pattern's gopt does. + k=$((i + 1)) + while ((k < n)); do + case "${w[k]}" in + -C | -c) k=$((k + 2)) ;; + -p | -P | --*) k=$((k + 1)) ;; + *) break ;; + esac + done + sub="${w[k]:-}" + rest=("${w[@]:k+1}") + force=0 + for word in "${rest[@]}"; do + [[ "$word" =~ ^-[a-zA-Z]*f || "$word" == --force ]] && force=1 + done + case "$sub" in + clean) + ((force)) && return 0 + ;; + worktree) + [[ "${rest[0]:-}" == remove ]] && ((force)) && return 0 + ;; + branch) + for word in "${rest[@]}"; do + [[ "$word" =~ ^-[a-zA-Z]*[dD] || "$word" =~ ^--delete ]] && return 0 + done + ;; + push) + # A dry-run (--dry-run, or an -n cluster with no -o push option) is a preview. + del=0 dry=0 + for word in "${rest[@]}"; do + [[ "$word" =~ ^--delete || "$word" =~ ^-[a-np-zA-Z]*d || "$word" =~ ^[+]?:. ]] && del=1 + [[ "$word" == --dry-run || "$word" =~ ^-[a-np-zA-Z]*n[a-np-zA-Z]*$ ]] && dry=1 + done + ((del && !dry)) && return 0 + ;; + reset) + [[ "${rest[0]:-}" == --hard ]] && return 0 + ;; + checkout) + [[ "${rest[0]:-}" == -- ]] && return 0 + ;; + stash) + [[ "${rest[0]:-}" == drop || "${rest[0]:-}" == clear ]] && return 0 + ;; + *) ;; + esac + ;; + *) ;; + esac + done + ((script && apply)) && return 0 + return 1 +} + +ARGV_HIT=0 +# shellcheck disable=SC2329 # invoked indirectly as the hook::bash_parse_segments callback +argv_segment() { + ((ARGV_HIT)) && return 0 + # `bash -c ''` and kin: the operand is a command line, parsed in turn. + if hook::shell_c_operand "$@"; then + hook::bash_parse_segments "$HOOK_SHELL_C_OPERAND" argv_segment + return 0 + fi + argv_destructive "$@" && ARGV_HIT=1 + return 0 +} + +# is_destructive_bash : is_destructive() for the Bash tool, over parsed +# segments instead of the raw string. Needs hook-utils.sh to be sourced. +is_destructive_bash() { + ARGV_HIT=0 + hook::bash_parse_segments "$1" argv_segment + ((ARGV_HIT)) +} + # jq parses the hook payload. Without it the ack prefix cannot be verified, so # the guard degrades FAIL-CLOSED: destructive patterns matched against the raw # JSON payload block unconditionally (no ack path) until jq is installed. JSON @@ -132,6 +269,25 @@ fi CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)" [[ -n "$CMD" ]] || exit 0 TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)" +SESSION="$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)" + +# Run-open marker (see Lifetime above). No live marker for this session means no +# clean run is open, so the guard stays out of the way. When the marker path +# cannot be resolved, the guard stays armed. +if run_marker_path_to MARKER_FILE "${CLAUDE_PLUGIN_DATA:-}" "$SESSION"; then + run_marker_state_to MARKER_STATE "$MARKER_FILE" + case "$MARKER_STATE" in + absent) exit 0 ;; + stale) + rm -f "$MARKER_FILE" + exit 0 + ;; + *) run_marker_write "$MARKER_FILE" ;; + esac +fi + +# shellcheck source=hook-utils.sh +source "$SCRIPT_DIR/hook-utils.sh" # Acknowledged path: the skill's confirmation gate prefixes the command with an # assignment. The ack is a deny-to-allow widening, so it is accepted only as @@ -170,7 +326,13 @@ if is_acknowledged "$CMD" "$TOOL"; then exit 0 fi -if is_destructive "$CMD"; then +DESTRUCTIVE=0 +if [[ "$TOOL" == Bash ]]; then + is_destructive_bash "$CMD" && DESTRUCTIVE=1 +else + is_destructive "$CMD" && DESTRUCTIVE=1 +fi +if ((DESTRUCTIVE)); then # shellcheck disable=SC2016 # literal `$env:` is the PowerShell spelling case "$TOOL" in Bash) ack_form='CLEAN_GUARD_ACK=1 ' ;; diff --git a/plugins/repo-hygiene/skills/clean/scripts/destructive-guard.test.sh b/plugins/repo-hygiene/skills/clean/scripts/destructive-guard.test.sh index 8f2aaaa1d3..c750f612ca 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/destructive-guard.test.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/destructive-guard.test.sh @@ -12,27 +12,47 @@ CASE_NUM=0 # shellcheck source=lib/test-helpers.sh source "$SCRIPT_DIR/lib/test-helpers.sh" +# shellcheck source=lib/run-marker.sh +source "$SCRIPT_DIR/lib/run-marker.sh" + +# The guard gates only while a run-open marker is live for the payload's +# session_id under CLAUDE_PLUGIN_DATA. Every case below sends SESSION_ID, and the +# marker is opened once here; section 2.0 changes its state on purpose. +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT +export CLAUDE_PLUGIN_DATA="$TEST_ROOT/data" +SESSION_ID="sess-live" +run_marker_path_to LIVE_MARKER "$CLAUDE_PLUGIN_DATA" "$SESSION_ID" +run_marker_write "$LIVE_MARKER" + # guard_exit [tool_name]. Omitting the tool defaults to Bash; passing # an explicit empty string sends an empty tool_name (`${2-…}`, not `${2:-…}`). guard_exit() { - jq -n --arg c "$1" --arg t "${2-Bash}" '{tool_name:$t, tool_input:{command:$c}}' | bash "$SCRIPT" >/dev/null 2>&1 + jq -n --arg c "$1" --arg t "${2-Bash}" --arg s "$SESSION_ID" '{tool_name:$t, session_id:$s, tool_input:{command:$c}}' | bash "$SCRIPT" >/dev/null 2>&1 echo $? } # guard_exit_rawtool . tool_name is sent as a raw JSON # value, so non-string payloads (null, a number) reach the guard as themselves. guard_exit_rawtool() { - jq -n --argjson t "$1" --arg c "$2" '{tool_name:$t, tool_input:{command:$c}}' | bash "$SCRIPT" >/dev/null 2>&1 + jq -n --argjson t "$1" --arg c "$2" --arg s "$SESSION_ID" '{tool_name:$t, session_id:$s, tool_input:{command:$c}}' | bash "$SCRIPT" >/dev/null 2>&1 echo $? } # guard_exit_notool . Payload with no tool_name key at all. guard_exit_notool() { - jq -n --arg c "$1" '{tool_input:{command:$c}}' | bash "$SCRIPT" >/dev/null 2>&1 + jq -n --arg c "$1" --arg s "$SESSION_ID" '{session_id:$s, tool_input:{command:$c}}' | bash "$SCRIPT" >/dev/null 2>&1 + echo $? +} + +# guard_exit_session . Bash payload for another session. +guard_exit_session() { + jq -n --arg c "$2" --arg s "$1" '{tool_name:"Bash", session_id:$s, tool_input:{command:$c}}' | bash "$SCRIPT" >/dev/null 2>&1 echo $? } SKILL_MD="$(cd "$SCRIPT_DIR/.." && pwd)/SKILL.md" +MARKER_SCRIPT="$SCRIPT_DIR/clean-run-marker.sh" # --- 1. --help contract -------------------------------------------------------- @@ -40,6 +60,128 @@ help_out=$(bash "$SCRIPT" --help 2>&1) assert_exit "--help exits 0" 0 $? assert_contains "--help names the ack prefix" "$help_out" "CLEAN_GUARD_ACK" +# --- 2.0 Precision: destructive text inside a quoted argument is inert ------------- +# These ran as blocks before argv matching: `gh issue comment --body '... git +# branch -D ...'` is the production false positive (#7333). The live marker is +# open, so only the parse decides. + +for cmd in \ + "gh issue comment 7333 --body 'cleanup ran git branch -D feature/x'" \ + "gh issue create --title t --body \"run rm -rf build/ then git reset --hard\"" \ + "gh issue comment 1 --body 'git push origin --delete x and git stash drop'" \ + "gh pr comment 1 --body 'bash scripts/clean-caches.sh --apply'" \ + "echo 'git clean -fdx'" \ + "git commit -m 'drop git branch -D from the docs'" \ + "gh issue create --body-file - <<'EOF' +the body says git branch -D x and rm -rf build/ +EOF"; do + assert_exit "stays quiet on quoted text: $cmd" 0 "$(guard_exit "$cmd")" +done + +for cmd in \ + "bash -c 'rm -rf build'" \ + "sh -c \"git clean -fdx\"" \ + "bash -c 'echo hi; git branch -D x'"; do + assert_exit "blocks a shell -c operand: $cmd" 2 "$(guard_exit "$cmd")" +done +assert_exit "allows a shell -c operand that is benign" 0 "$(guard_exit "bash -c \"echo 'git branch -D'\"")" + +assert_exit "still blocks a real branch delete after a quiet comment" 2 \ + "$(guard_exit "gh issue comment 1 --body 'x'; git branch -D feature/x")" + +# The parser comes from the generated copy of lib/hook-utils.sh. One direct call +# shows it keeps a quoted argument as a single word, which is what the guard's +# precision rests on. +parsed_words="$( + # shellcheck source=hook-utils.sh + source "$SCRIPT_DIR/hook-utils.sh" + # shellcheck disable=SC2329 # invoked indirectly as the parser callback + report() { printf '%s|' "$#" "$5"; } + hook::bash_parse_segments "gh issue comment --body 'git branch -D x'" report +)" +assert_contains "generated hook-utils copy parses a quoted argument as one word" "$parsed_words" "5|git branch -D x|" + +# --- 2.1 Run-open marker scopes the guard ----------------------------------------- + +rm -f "$LIVE_MARKER" +assert_exit "no marker: git branch -D x is allowed" 0 "$(guard_exit "git branch -D x")" +assert_exit "no marker: rm -rf is allowed" 0 "$(guard_exit "rm -rf build/")" +assert_exit "no marker: PowerShell Remove-Item is allowed" 0 "$(guard_exit "Remove-Item -Recurse -Force obj" PowerShell)" + +run_marker_write "$LIVE_MARKER" +assert_exit "live marker: git branch -D x is blocked" 2 "$(guard_exit "git branch -D x")" +assert_exit "live marker: another session is not gated" 0 "$(guard_exit_session "sess-other" "git branch -D x")" +assert_exit "live marker: unusable session id keeps the guard armed" 2 "$(guard_exit_session "../escape" "git branch -D x")" + +# Marker past the idle timeout: allowed, and the stale file is removed. +printf '1\n' >"$LIVE_MARKER" +assert_exit "stale marker: git branch -D x is allowed" 0 "$(guard_exit "git branch -D x")" +if [[ -e "$LIVE_MARKER" ]]; then + fail "stale marker is removed" "removed" "present" +else + pass "stale marker is removed" +fi + +# A gated call refreshes a live marker, so the timeout counts from the last call. +printf '%s\n' "$(($(printf '%(%s)T' -1) - RUN_MARKER_IDLE_SECONDS + 60))" >"$LIVE_MARKER" +assert_exit "nearly idle marker: still blocks" 2 "$(guard_exit "git branch -D x")" +read -r refreshed <"$LIVE_MARKER" +if ((refreshed >= $(printf '%(%s)T' -1) - 5)); then + pass "a gated call refreshes the marker" +else + fail "a gated call refreshes the marker" "within 5s of now" "$refreshed" +fi + +# An unreadable stamp keeps the guard armed. +printf 'garbage\n' >"$LIVE_MARKER" +assert_exit "unreadable marker: still blocks" 2 "$(guard_exit "git branch -D x")" + +# No resolvable data dir: armed, not silently inert. +unresolved=$( + jq -n --arg s "$SESSION_ID" '{tool_name:"Bash", session_id:$s, tool_input:{command:"git branch -D x"}}' | + CLAUDE_PLUGIN_DATA="" bash "$SCRIPT" >/dev/null 2>&1 + echo $? +) +assert_exit "unset CLAUDE_PLUGIN_DATA keeps the guard armed" 2 "$unresolved" +no_session=$( + jq -n '{tool_name:"Bash", tool_input:{command:"git branch -D x"}}' | bash "$SCRIPT" >/dev/null 2>&1 + echo $? +) +assert_exit "missing session_id keeps the guard armed" 2 "$no_session" +run_marker_write "$LIVE_MARKER" + +# The marker script: mutating tiers arm the guard, the scan tiers never write. +for tier in scan scan-batch; do + bash "$MARKER_SCRIPT" open --tier "$tier" --session "sess-$tier" --data-dir "$CLAUDE_PLUGIN_DATA" >/dev/null 2>&1 + assert_exit "open --tier $tier exits 0" 0 $? + if [[ -e "$CLAUDE_PLUGIN_DATA/clean-run/sess-$tier.open" ]]; then + fail "open --tier $tier writes no marker" "absent" "present" + else + pass "open --tier $tier writes no marker" + fi +done +for tier in caches build git stash tree all caches-batch tree-batch; do + bash "$MARKER_SCRIPT" open --tier "$tier" --session "sess-$tier" --data-dir "$CLAUDE_PLUGIN_DATA" >/dev/null 2>&1 + assert_exit "open --tier $tier exits 0" 0 $? + if [[ -e "$CLAUDE_PLUGIN_DATA/clean-run/sess-$tier.open" ]]; then + pass "open --tier $tier writes the marker" + else + fail "open --tier $tier writes the marker" "present" "absent" + fi +done +assert_exit "marker armed by the script gates the guard" 2 "$(guard_exit_session "sess-caches" "git branch -D x")" +bash "$MARKER_SCRIPT" close --session "sess-caches" --data-dir "$CLAUDE_PLUGIN_DATA" >/dev/null 2>&1 +assert_exit "close exits 0" 0 $? +assert_exit "closed run: the guard lets git branch -D x through" 0 "$(guard_exit_session "sess-caches" "git branch -D x")" +bash "$MARKER_SCRIPT" close --session "sess-caches" --data-dir "$CLAUDE_PLUGIN_DATA" >/dev/null 2>&1 +assert_exit "close is idempotent" 0 $? +bash "$MARKER_SCRIPT" open --tier caches --session "sess-x" --data-dir '${CLAUDE_PLUGIN_DATA}' >/dev/null 2>&1 +assert_exit "open refuses an unsubstituted data dir" 2 $? +bash "$MARKER_SCRIPT" open --tier caches --session "../x" --data-dir "$CLAUDE_PLUGIN_DATA" >/dev/null 2>&1 +assert_exit "open refuses an unusable session id" 2 $? +bash "$MARKER_SCRIPT" open --session "sess-x" --data-dir "$CLAUDE_PLUGIN_DATA" >/dev/null 2>&1 +assert_exit "open without --tier is a usage error" 2 $? + # --- 2. Destructive commands blocked (exit 2) ----------------------------------- for cmd in \ @@ -367,7 +509,7 @@ assert_exit "empty tool_input exits 0" 0 "$empty" # sandbox is verified functional first and the cases are skipped when it isn't. NOJQ_DIR="$(mktemp -d)" -trap 'rm -rf "$NOJQ_DIR"' EXIT +trap 'rm -rf "$TEST_ROOT" "$NOJQ_DIR"' EXIT for bin in bash cat grep sed; do ln -s "$(command -v "$bin")" "$NOJQ_DIR/$bin" 2>/dev/null || true done diff --git a/plugins/repo-hygiene/skills/clean/scripts/hook-utils.sh b/plugins/repo-hygiene/skills/clean/scripts/hook-utils.sh new file mode 100644 index 0000000000..358d794a99 --- /dev/null +++ b/plugins/repo-hygiene/skills/clean/scripts/hook-utils.sh @@ -0,0 +1,5048 @@ +# GENERATED from lib/hook-utils.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# shellcheck shell=bash +# Shared hook utility library for this marketplace's hook plugins. Sourced +# (not executed): kill switch, file_path parsing + path normalization, +# repo-root resolution, additionalContext accumulator, telemetry envelope. +# +# SINGLE SOURCE OF TRUTH: lib/hook-utils.sh at the marketplace repo root. The +# copies at plugins/*/hooks/hook-utils.sh exist because installed plugins are +# cache-isolated and must be self-contained — never edit a copy. Edit the +# source and run scripts/sync-shared-copies.sh; CI rejects drifted copies. +# +# CALLING CONVENTION: a helper that produces a value is spelled +# `hook::_to [args…]` and writes into the caller's variable. That +# is the one convention; call it directly rather than wrapping it in `$( )`. +# GNU Bash forks a subshell for every command substitution even when the body +# is only builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution), and on Windows Git Bash a +# fork is a non-copy-on-write Win32 CreateProcess costing milliseconds, so a +# capture around a `_to` helper is pure loss on hooks that run per edit. +# Six helpers still print instead — hook::json_escape, hook::physical_path, +# hook::repo_root, hook::buffer_stdin, hook::raw_file_path and +# hook::read_file_path (which reads fd0; a hook holding the buffered payload +# calls hook::read_file_path_to). Each carries, at its definition, the reason +# its print form is kept. + +# Guard against double-sourcing. +[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0 +readonly _HOOK_UTILS_LOADED=1 + +# Per-hook kill switch via the plugin's _enabled userConfig boolean, +# read from the hook-process CLAUDE_PLUGIN_OPTION__ENABLED mirror. +# Exits 0 (allow) if disabled. Place after source, before stdin parsing. +# hook::check_enabled "MARKDOWN_FORMAT" # checks CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED +# +# Deliberately NOT layered with a marketplace-specific fleet switch. Claude Code +# already ships the coarse controls, and a parallel scheme here would become a +# second source of truth for the same question: +# * `--safe-mode` / `CLAUDE_CODE_SAFE_MODE` +# (https://code.claude.com/docs/en/cli-reference#cli-flags) +# * `disableAllHooks` +# (https://code.claude.com/docs/en/settings-reference#disableallhooks) +# * `claude plugin disable|enable ` +# (https://code.claude.com/docs/en/plugins/cli-reference#plugin-disable) +# Each pointer says what the control covers (as of 2026-10-11; recheck when one +# of those sections is removed or stops covering hooks). +# This helper stays scoped to the one thing it owns: the plugin's own +# `_enabled` userConfig boolean, surfaced to hook processes as the native +# `$CLAUDE_PLUGIN_OPTION_` mirror. + +# hook::is_enabled — the same check as a PREDICATE. Returns 0 when the +# plugin should run, 1 when it should not. For callers that must not terminate +# the process on a "disabled" answer, such as a wrapper that must skip its own +# side effect and still pass the wrapped command's output through. +hook::is_enabled() { + local var_name="CLAUDE_PLUGIN_OPTION_${1}_ENABLED" + [[ "${!var_name:-true}" == "true" ]] +} + +hook::check_enabled() { + hook::is_enabled "$1" || exit 0 +} + +# --- Prerequisite visibility -------------------------------------------------- +# Doctrine: a missing runtime prerequisite must surface to BOTH the agent +# (additionalContext) and the user (systemMessage) — a silently skipped feature +# is a defect. Everything in this section is jq-FREE by design: the most common +# missing prerequisite is jq itself. + +# JSON-escape a string for embedding in a hand-built JSON document. Escapes +# backslash, double quote, and the line-structure control bytes by name +# (\n \r \t); the remaining C0 bytes JSON forbids raw are dropped — notice text +# never carries meaningful control bytes beyond line structure. Byte-safe under +# UTF-8: every escaped byte is ASCII, and UTF-8 continuation bytes are >= 0x80. +# +# hook::json_escape_to writes in THIS shell and is the form to +# call. hook::json_escape is kept as a print form for the two Stop/PostCompact +# marker builders that splice an escaped field into a single-quoted JSON +# literal, where a capture is the shape; a `$(hook::json_escape …)` capture is +# still a fork even when the body is only builtins (Command Execution +# Environment, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). The previous +# `printf | tr -d` pipeline added two more process creations and a `tr` exec +# per notice; Cygwin's fork is a non-copy-on-write Win32 CreateProcess +# (Cygwin User's Guide, "Process Creation": "fork will almost certainly +# always be inefficient under Win32"). Residual C0 deletion is the same +# character class tr used (`\001-\010\013\014\016-\037`); NUL cannot appear +# in a bash string, so tr's `\000` was already unrepresentable. +hook::json_escape_to() { + local __hu_s="$2" + __hu_s="${__hu_s//\\/\\\\}" + __hu_s="${__hu_s//\"/\\\"}" + __hu_s="${__hu_s//$'\n'/\\n}" + __hu_s="${__hu_s//$'\r'/\\r}" + __hu_s="${__hu_s//$'\t'/\\t}" + if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then + __hu_s="${__hu_s//[$'\001'-$'\010'$'\013'$'\014'$'\016'-$'\037']/}" + fi + printf -v "$1" '%s' "$__hu_s" +} + +hook::json_escape() { + local __hu_e + hook::json_escape_to __hu_e "$1" + printf '%s' "$__hu_e" +} + +# Emit hook JSON carrying an agent-channel context (additionalContext) and/or a +# user-channel message (systemMessage) as ONE document, so a run that has both +# lint findings and a pending skip notice composes them here rather than +# printing twice; how stdout is parsed as JSON is +# https://code.claude.com/docs/en/hooks#exit-code-0 (as of 2026-10-11; recheck +# when that section starts reading more than one JSON object from stdout). Either +# channel may be empty; emits nothing when both are. One entry point for both +# channels, so a hook with only one of them passes "" for the other rather +# than picking between two spellings of the same emit. +# hook::emit_channels PostToolUse "$ctx" "$sysmsg" +# hook::emit_channels PreToolUse "$ctx" "" # agent channel only +hook::emit_channels() { + local event="$1" ctx="$2" sysmsg="$3" + [[ -n "$ctx" || -n "$sysmsg" ]] || return 0 + local __hu_ee __hu_ec __hu_es out="{" + if [[ -n "$ctx" ]]; then + hook::json_escape_to __hu_ee "$event" + hook::json_escape_to __hu_ec "$ctx" + out+='"hookSpecificOutput":{"hookEventName":"'"$__hu_ee"'","additionalContext":"'"$__hu_ec"'"}' + [[ -n "$sysmsg" ]] && out+="," + fi + if [[ -n "$sysmsg" ]]; then + hook::json_escape_to __hu_es "$sysmsg" + out+='"systemMessage":"'"$__hu_es"'"' + fi + out+="}" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" +} + +# Skip notice, gated per channel by the last hook::notice_once. The caller exits +# 0 right after unless it composes via hook::emit_channels itself. +# hook::emit_skip_notice PostToolUse "$text" # same text, both channels +# hook::emit_skip_notice PostToolUse "$model" "$user" # one text per channel +# hook::emit_skip_notice PostToolUse "" "$user" # user channel only +# +# The model gets when HOOK_NOTICE_TO_MODEL is 1 and the user gets +# when HOOK_NOTICE_TO_USER is 1; both are 1 unless hook::notice_once +# just cleared one. A `PATH probed:` line in either text goes to stderr, which +# an exit-0 hook sends to the debug log, never to a channel. +hook::emit_skip_notice() { + local event="$1" model="$2" user="${3-$2}" probe=$'\nPATH probed: ' path="" + if [[ "$model" == *"$probe"* ]]; then + path="${model#*"$probe"}" + model="${model%%"$probe"*}" + fi + if [[ "$user" == *"$probe"* ]]; then + path="${user#*"$probe"}" + user="${user%%"$probe"*}" + fi + [[ -n "$path" ]] && printf 'PATH probed: %s\n' "$path" >&2 + [[ "${HOOK_NOTICE_TO_MODEL:-1}" == 1 ]] || model="" + [[ "${HOOK_NOTICE_TO_USER:-1}" == 1 ]] || user="" + HOOK_NOTICE_TO_MODEL=1 + HOOK_NOTICE_TO_USER=1 + hook::emit_channels "$event" "$model" "$user" +} + +# The exit-0 notice for hook::buffer_stdin rc 3 (a JSON payload cut short by +# the pipe CLOSING mid-document; a pipe that stalls on such a prefix is rc 2 +# and never reaches this). Not once-per-session: each occurrence is one tool +# call that ran unevaluated. User channel only, since the user is the one who +# can act on a starved host; the same line goes to stderr next to +# buffer_stdin's own diagnostic. is accepted for the callers that pass +# it and is not used. The caller exits 0 right after. +# hook::stdin_cut_short_notice PreToolUse "guardrails block-hook-bypass" +hook::stdin_cut_short_notice() { + local msg="$2: hook stdin was cut short, so this tool call ran unchecked. If it recurs, the host is starved; see the stdin_read_timeout option." + echo "$msg" >&2 + hook::emit_channels "" "" "$msg" +} + +# systemMessage-only variant for a hook with nothing for the model. Notification +# hooks do not use it, per https://code.claude.com/docs/en/hooks#notification +# (as of 2026-10-11; recheck when that section starts showing a Notification +# hook's systemMessage). +hook::emit_system_message() { + hook::emit_channels "" "" "$1" +} + +# hook::session_agent_to : the latch keys from +# the raw hook input, jq-free. Session is session_id, else no-session. Agent is +# agent_id, else the transcript_path basename (the main agent), else no-agent. +# Both are reduced to [A-Za-z0-9_-] so they can name a marker file. +hook::session_agent_to() { + local __hu_sa_s="no-session" __hu_sa_a="no-agent" + if [[ "$3" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then + __hu_sa_s="${BASH_REMATCH[1]//[^A-Za-z0-9_-]/-}" + fi + if [[ "$3" =~ \"agent_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then + __hu_sa_a="${BASH_REMATCH[1]}" + elif [[ "$3" =~ \"transcript_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]]; then + __hu_sa_a="${BASH_REMATCH[1]##*/}" + __hu_sa_a="${__hu_sa_a%.jsonl}" + fi + __hu_sa_a="${__hu_sa_a//[^A-Za-z0-9_-]/-}" + printf -v "$1" '%s' "$__hu_sa_s" + printf -v "$2" '%s' "${__hu_sa_a:-no-agent}" +} + +# hook::_state_dir_to : $CLAUDE_PLUGIN_DATA/, created on +# first use, with files older than 7 days pruned once per process. Returns 1 +# when there is no data directory or it cannot be created. +hook::_state_dir_to() { + [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]] || return 1 + local __hu_sd="${CLAUDE_PLUGIN_DATA%/}/$2" + # `-d` before `mkdir -p`: mkdir is an external command. + if [[ ! -d "$__hu_sd" ]]; then + mkdir -p "$__hu_sd" 2>/dev/null || return 1 + fi + # One `find` per hook process and directory: the markers are tiny and the + # process short-lived, so a second walk could find nothing newly stale. + local __hu_sd_var="_HOOK_PRUNED_${2//[^A-Za-z0-9_]/_}" + if [[ -z "${!__hu_sd_var:-}" ]]; then + find "$__hu_sd" -maxdepth 1 -type f -mtime +7 -delete 2>/dev/null || true + printf -v "$__hu_sd_var" '%s' 1 + fi + printf -v "$1" '%s' "$__hu_sd" +} + +# hook::_file_key_to : a marker-name fragment for . Lossy +# (characters outside [A-Za-z0-9._-] fold to _ and a long path keeps its tail), +# so callers store the full path in the marker and compare it. +hook::_file_key_to() { + local __hu_fk="${2//[^A-Za-z0-9._-]/_}" + ((${#__hu_fk} > 120)) && __hu_fk="${__hu_fk: -120}" + printf -v "$1" '%s' "$__hu_fk" +} + +# hook::_claim : create only if absent, atomically (noclobber), so +# of two hooks racing on one marker exactly one wins. 0 when this call created +# it, 1 when it already existed, 2 when it could not be written. +hook::_claim() { + (set -C && : >"$1") 2>/dev/null && return 0 + [[ -e "$1" ]] && return 1 + return 2 +} + +# Skip-notice latch: one notice per channel, never renewed. Returns 0 when +# either channel is due, 1 when neither is, and sets: +# HOOK_NOTICE_TO_MODEL 1 on the first call for in this (session, agent) +# HOOK_NOTICE_TO_USER 1 on the first call for in this session +# hook::emit_skip_notice reads both. The model marker keys on the agent because +# a subagent does not see what the main agent was told; the user marker keys on +# the session because the user sees one transcript. prerequisites.mjs probe +# writes the user marker at SessionStart, so its notice and this one are one +# notice to the user. +# +# HOOK_NOTICE_KIND is `full` when the user channel is due, `model` when only +# the model channel is, `silent` otherwise; it remains for callers that read +# it, and HOOK_NOTICE_TO_USER says the same. A third argument is accepted and +# ignored. +# +# Fails open toward visibility: without a data directory both channels are due +# every time. +# hook::notice_once "my-plugin-jq" "$INPUT" && hook::emit_skip_notice ... +HOOK_NOTICE_TO_MODEL=1 +HOOK_NOTICE_TO_USER=1 +HOOK_NOTICE_KIND=full + +# shellcheck disable=SC2034 # HOOK_NOTICE_KIND is read by callers, not here +hook::notice_once() { + local key="$1" session agent dir + HOOK_NOTICE_TO_MODEL=1 + HOOK_NOTICE_TO_USER=1 + HOOK_NOTICE_KIND=full + hook::session_agent_to session agent "${2:-}" + hook::_state_dir_to dir skip-notices || return 0 + local model="$dir/${key}.${session}.${agent}" user="$dir/${key}.${session}.user" + hook::_claim "$model" + case $? in 1) HOOK_NOTICE_TO_MODEL=0 ;; 2) return 0 ;; *) ;; esac + hook::_claim "$user" + case $? in 1) HOOK_NOTICE_TO_USER=0 ;; 2) return 0 ;; *) ;; esac + if [[ "$HOOK_NOTICE_TO_USER" == 1 ]]; then + return 0 + elif [[ "$HOOK_NOTICE_TO_MODEL" == 1 ]]; then + HOOK_NOTICE_KIND=model + return 0 + fi + HOOK_NOTICE_KIND=silent + HOOK_NOTICE_TO_MODEL=1 + HOOK_NOTICE_TO_USER=1 + return 1 +} + +# hook::once_per_file : 0 the first time per (key, session, +# agent, file), 1 after. For a hint worth giving once per file rather than on +# every report about it. Fails open (0) without a data directory. +# hook::once_per_file my-plugin-hint "$INPUT" "$FILE" && ctx+=" " +hook::once_per_file() { + local session agent dir fkey prev="" + hook::session_agent_to session agent "$2" + hook::_state_dir_to dir skip-notices || return 0 + hook::_file_key_to fkey "$3" + local marker="$dir/${1}.${session}.${agent}.f.${fkey}" + if [[ -f "$marker" ]]; then + IFS= read -r prev <"$marker" || true + [[ "$prev" == "$3" ]] && return 1 + fi + printf '%s\n' "$3" 2>/dev/null >"$marker" || true + return 0 +} + +# Best-effort jq-free extraction of tool_input.file_path from the raw hook +# input, for the applicability pre-filter an extension-scoped hook runs BEFORE +# its jq gate — a missing-jq notice must never fire for an edit the hook would +# not process anyway (e.g. a README edit reaching a workflow-lint hook whose +# Write|Edit matcher is broader than its file filter). The value is returned +# JSON-escaped (backslashes doubled); that is fine for extension/segment +# matching, which is all the pre-filter does. Returns 1 when no file_path is +# present. +# hook::raw_file_path_to RAW_FILE "$INPUT" || exit 0 +# is left unchanged on a return of 1. +hook::raw_file_path_to() { + [[ "$2" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]] || return 1 + [[ -n "${BASH_REMATCH[1]}" ]] || return 1 + printf -v "$1" '%s' "${BASH_REMATCH[1]}" +} + +# Print form, kept for the hooks that capture it once at top level +# (instruction-placement's index-drift hook), off the per-edit prologue. +# RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0 +hook::raw_file_path() { + local __hu_raw + hook::raw_file_path_to __hu_raw "$1" || return 1 + printf '%s' "$__hu_raw" +} + +# ============================================================================ +# THE jq GATE — TWO POSTURES, AND WHY THERE ARE TWO (#2146) +# ============================================================================ +# +# A hook that cannot parse its payload has exactly two honest moves: let the +# tool call through (fail OPEN) or deny it (fail CLOSED). This library offers +# both, as two named functions, and the choice belongs to the CALLING hook. The +# reasoning lives HERE, at the decision point, not only at the call sites — +# before #2146 every call site asserted a posture in a comment and nothing where +# the posture is actually implemented explained it. +# +# hook::require jq fails OPEN — the default, and correct for most hooks +# hook::require_jq_blocking fails CLOSED — for a guard that blocks an +# irreversible operation +# +# WHY FAIL OPEN IS THE DEFAULT. Most hooks in this marketplace are advisory or +# cosmetic: a formatter, a lint pass, a context injector, a detect-then-judge +# oracle. Their finding is a prompt, not a verdict. Blocking a user's tool call +# because an OPTIONAL formatting hook could not find an OPTIONAL dependency +# inverts the cost: the guard's job is worth less than the work it would stop. +# The once per session and agent notice is what keeps that degradation honest +# rather than silent — the user and the agent are both told the hook is off. +# +# WHY A MINORITY MUST FAIL CLOSED. A guard whose job is to stop an IRREVERSIBLE +# operation cannot be a suggestion. Its whole value is that it is there when +# nobody is watching, and "somewhere without jq" is not an exotic state — it is +# the default state of a machine that has not installed one dependency. A guard +# that a missing dependency silently switches off is not a guard; it is a guard +# on machines that happen to be configured for it. #2146 measured this: with jq +# unreachable, `git push --force origin main` was ALLOWED by block-dangerous-git, +# after one notice, for the rest of the session. +# +# WHICH HOOKS ARE IN THAT MINORITY — the criterion is mechanical, and it is +# INTERNAL CONSISTENCY, not a taste judgment about severity. A hook belongs in +# the fail-closed class iff it ALREADY fails closed on some other +# "I cannot parse this input" condition. Every such hook today carries a +# MAX_COMMAND_LEN ceiling above which an unparsable command is denied unread; +# plugins/guardrails/hooks/require-jq-posture.test.sh derives the class from +# that ceiling, so no list here can go stale. +# +# block-dangerous-git and block-no-verify, the first two members, held two +# opposite postures toward the same question (an over-long command is hostile +# and blocked; a missing jq is fine and skipped), which meant an author who +# could not fit a dangerous command under 16384 characters could simply be on a +# machine without jq. That contradiction is what #2146 reports, and resolving it +# is all this class is for. Because the posture test derives membership from the +# ceiling, the class cannot drift. +# +# DELIBERATELY NOT WIDENED. block-hook-bypass and block-noncanonical-commit also +# exit 2, and block-hook-bypass carries the same "the only supported deliberate +# bypass is the kill switch" sentence. They stay fail-open: they guard a FILE +# WRITE or a message shape, both trivially reversible, and neither holds the +# internal contradiction above. Severity is a slope; "already fails closed +# elsewhere in the same script" is a line. If one of them grows a length ceiling +# it joins the class, and the posture test will say so. +# +# WHY TWO FUNCTIONS RATHER THAN ONE WITH A FLAG. A parameter's OMITTED value has +# to default to something, and the safe-looking default (fail open, matching +# today's behavior) means a guard that should fail closed but whose flag someone +# forgot fails open SILENTLY — which is the exact defect class #2146 reports, +# reintroduced at the API. Two names make the posture greppable, make the +# fail-closed path impossible to reach by accident, and make omission a visible +# choice instead of an invisible default. + +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: the hook::prereq_notice_to notice (once per channel, see +# hook::notice_once), then exit 0. Place after hook::check_enabled (and after +# any jq-free applicability pre-filter), passing the buffered stdin for session +# scoping. See the posture block above for when this is the WRONG choice. +# hook::require jq PostToolUse my-plugin "$INPUT" +# +# names the notice and its latch key; it never installs. +# +# No PATH line: a guard chain under one dispatcher (guardrails run-guards.sh) +# can still exit 2 after this notice. Where exit-2 stderr goes: on PreToolUse, +# https://code.claude.com/docs/en/hooks#exit-code-2 (Claude receives it as the +# tool's error); on other events, +# https://code.claude.com/docs/en/hooks#exit-code-2-behavior-per-event. Where +# exit-0 stderr goes: https://code.claude.com/docs/en/hooks#exit-code-0. As of +# 2026-10-11; recheck when the exit-code-2 section stops saying Claude receives +# a blocked PreToolUse hook's stderr, the exit-code-2-behavior-per-event table +# changes which events show exit-2 stderr to Claude, or the exit-code-0 section +# starts showing exit-0 stderr to Claude. The --no-path and stderr-deny notes +# below rest on this record. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local model user + if hook::prereq_notice_to model user "$1" "${4:-}" --label "$3" --no-path; then + hook::emit_skip_notice "$2" "$model" "$user" + fi + exit 0 +} + +# hook::prereq_notice_to [--label ] +# [--where ] [--install ] [--no-path] +# Composes the notice for a missing prerequisite from the plugin's +# prerequisites.json entry (docs/conventions/prerequisites/) and latches it per +# channel through hook::notice_once with key `