diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8536479c..137637fa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,6 +231,19 @@ jobs: - name: Run cross-plugin-source-drift tests run: bash scripts/check-cross-plugin-source-drift.test.sh + silent-skip-gate: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Check hook entry scripts for silent prerequisite skips + run: scripts/check-silent-skips.sh + - name: Run silent-skip-gate tests + run: bash scripts/check-silent-skips.test.sh + plugin-gate: runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -350,6 +363,7 @@ jobs: - hook-utils-sync - standards-contract-sync - cross-plugin-source-drift + - silent-skip-gate - plugin-gate - miro-plugin - runner-policy diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 548f82a77..48df81581 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.13.0", + "version": "0.13.1", "description": "Claude Code operations toolkit. Five skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 152672e59..7b5032a1a 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.13.1] + +### Changed + +- **`hook-telemetry-sink` quiet jq skip documented at the site** with a + `silent-skip-ok` annotation (the marketplace's new silent-skip CI gate). No + behavior change: the sink is fire-and-forget — its producer discards + stdout+stderr, so prerequisite visibility is owned by the producer side. + ## [0.13.0] ### Changed diff --git a/plugins/claude-ops/hooks/hook-telemetry-sink.sh b/plugins/claude-ops/hooks/hook-telemetry-sink.sh index 594375b86..24929dfd6 100755 --- a/plugins/claude-ops/hooks/hook-telemetry-sink.sh +++ b/plugins/claude-ops/hooks/hook-telemetry-sink.sh @@ -27,6 +27,8 @@ source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" INPUT=$(cat) [[ -n "$INPUT" ]] || exit 0 +# silent-skip-ok: fire-and-forget sink — the producer discards stdout+stderr, +# so no notice channel exists; the producer side owns prerequisite visibility. command -v jq >/dev/null 2>&1 || exit 0 # Single jq parse + required-key guard. Emit one mapped field per line (nothing diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index c18ec5e87..c9deb5e21 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.7.0", + "version": "0.7.1", "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", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index a303dd52c..6424c16f8 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,15 @@ 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.7.1] + +### Fixed + +- **`flag-commit-pr-skill-bypass` jq-absent skip is now visible** (prerequisite-visibility + doctrine). The hook previously no-op'd silently when `jq` was missing; it now writes the + same one-line stderr notice its sibling guardrails hooks emit ("advisory disabled — + install jq to enable") before exiting 0. + ## [0.7.0] ### Added diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index aef7d440a..a980b1367 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -52,8 +52,10 @@ hook::check_enabled "FLAG_COMMIT_PR_SKILL_BYPASS" start=${EPOCHREALTIME:-} # jq is required both to parse the tool payload and to read enabledPlugins. -# Fail OPEN (silent, advisory-only guard) when it is absent. +# Fail OPEN when it is absent, but make the degraded state visible rather than +# silently disabling the advisory (this hook never blocks either way). if ! command -v jq >/dev/null 2>&1; then + echo "guardrails/flag-commit-pr-skill-bypass: jq not found on PATH — advisory disabled (install jq to enable)." >&2 exit 0 fi diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 45b1b7b8d..7b1afeb14 100644 --- a/plugins/powershell-format/.claude-plugin/plugin.json +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "powershell-format", - "version": "0.4.0", + "version": "0.4.1", "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings.", "author": { "name": "Melodic Software", diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index d16ee0def..f63ba8714 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to the `powershell-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.4.1] + +### Changed + +- **Quiet pwsh-absent skip documented at the site** with a `silent-skip-ok` + annotation (the marketplace's new silent-skip CI gate). No behavior change: + absent `pwsh` remains a by-design not-applicable quiet skip, still recorded + via opt-in telemetry. + ## [0.4.0] ### Added diff --git a/plugins/powershell-format/hooks/powershell-format.sh b/plugins/powershell-format/hooks/powershell-format.sh index 7c9dd609b..187d60359 100755 --- a/plugins/powershell-format/hooks/powershell-format.sh +++ b/plugins/powershell-format/hooks/powershell-format.sh @@ -152,6 +152,8 @@ done # Resolve pwsh from PATH — never downloaded. Absent -> clean skip (a pwsh-less # contributor box, or a Linux cloud session without PowerShell). CI's PowerShell # job is the authoritative PSScriptAnalyzer gate, so nothing is lost locally. +# silent-skip-ok: not-applicable classification — absent pwsh is a by-design +# quiet skip on hosts without PowerShell; telemetry still records the skip. command -v pwsh >/dev/null 2>&1 || emit_skipped # PowerShell on Windows does not understand MSYS mount paths (/d/...). Convert diff --git a/scripts/check-silent-skips.sh b/scripts/check-silent-skips.sh new file mode 100755 index 000000000..0d67b07e4 --- /dev/null +++ b/scripts/check-silent-skips.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Gate: a hook that skips because a CLI prerequisite is absent must make the +# skip visible. The prerequisite-visibility doctrine classifies absence as +# required (stop with remediation), optional (visible warn + skip), or +# not-applicable (quiet) — a quiet skip is only ever a deliberate, +# documented classification, never a default. +# +# scripts/check-silent-skips.sh fail if any hook entry script +# silently skips on a missing CLI +# +# Two shapes are flagged in plugins/*/hooks/*.sh (entry scripts only — +# hook-utils.sh lib copies have their own sync gate and review; *.test.sh +# files exercise these patterns as fixtures): +# +# 1. same-line guard: command -v X ... || exit 0 (also `return 0`, or +# a skip-named helper such as `|| emit_skipped`) +# 2. block guard: if ! command -v X ...; then ... fi where the block +# reaches `exit 0` / `return 0` +# +# A flagged site passes when the skip is visible — the guard line or block +# carries one of the sanctioned visibility calls (hook::emit_skip_notice, +# hook::emit_system_message, hook::notice_once, hook::require_jq, or a +# stderr write) — or when it is a documented quiet classification: an +# annotation comment `# silent-skip-ok: ` on the guard line, in the +# comment block immediately above it, or inside the guard block. The +# annotation is the recorded decision; a bare quiet skip is a defect. +# +# This is a grep-level tripwire, not a semantic proof: it does not chase +# helper-function bodies and does not flag a positive-form +# `if command -v X; then ... else exit 0` (the current corpus uses the +# else-branch for its visible notice). Its job is to stop the historical +# regression — a new hook quietly no-op'ing when an optional CLI is absent. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +errors=0 + +for hook in plugins/*/hooks/*.sh; do + base="${hook##*/}" + case "$base" in + hook-utils.sh | *.test.sh) continue ;; + *) ;; + esac + [[ -f "$hook" ]] || continue + + out=$(awk ' + function is_annotated(l) { return l ~ /#[[:space:]]*silent-skip-ok:/ } + function is_comment(l) { return l ~ /^[[:space:]]*#/ } + function is_visible(l) { + return l ~ /hook::emit_skip_notice/ || l ~ /hook::emit_system_message/ || + l ~ /hook::notice_once/ || l ~ /hook::require_jq/ || l ~ />&2/ + } + { + line = $0 + + # Shape 2: block guard. Statement-per-line shell (shfmt-formatted), so + # `if`/`fi` sit at line start; comment lines never open or close a block. + if (in_block) { + if (line ~ /^[[:space:]]*if[[:space:]]/) depth++ + if (line ~ /^[[:space:]]*fi([[:space:]]*(#.*)?)?$/) depth-- + if (is_visible(line)) block_visible = 1 + if (is_annotated(line)) block_annotated = 1 + if (line ~ /(^|[[:space:]])(exit|return)[[:space:]]+0([[:space:]]|$|;)/) block_skips = 1 + if (depth == 0) { + if (block_skips && !block_visible && !block_annotated) + printf "%d: silent block skip: `if ! command -v ...` reaches exit/return 0 with no visible notice\n", block_start + in_block = 0 + } + next + } + + # An annotation anywhere in the contiguous comment block directly above + # a guard sanctions that guard; any code line ends the pending block. + annotated_above = pending_annot + if (is_comment(line)) { + if (is_annotated(line)) pending_annot = 1 + } else { + pending_annot = 0 + } + + if (line ~ /^[[:space:]]*(el)?if[[:space:]]+!.*command -v/) { + in_block = 1; depth = 1; block_start = NR + block_visible = is_visible(line) + block_annotated = is_annotated(line) || annotated_above + block_skips = 0 + next + } + + # Shape 1: same-line guard. + if (line ~ /command -v/ && + (line ~ /\|\|[[:space:]]*exit[[:space:]]+0/ || + line ~ /\|\|[[:space:]]*return[[:space:]]+0/ || + line ~ /\|\|[[:space:]]*[A-Za-z_][A-Za-z0-9_:]*[Ss]kip/)) { + if (!is_visible(line) && !is_annotated(line) && !annotated_above) + printf "%d: silent skip: `command -v ... ||` quiet skip with no visible notice\n", NR + } + } + END { + if (in_block && block_skips && !block_visible && !block_annotated) + printf "%d: silent block skip: unterminated `if ! command -v` block reaches exit/return 0 with no visible notice\n", block_start + } + ' "$hook") + + if [[ -n "$out" ]]; then + while IFS= read -r v; do + echo "SILENT SKIP: ${hook}:${v}" >&2 + errors=$((errors + 1)) + done <<<"$out" + fi +done + +if ((errors > 0)); then + { + echo + echo "A missing-CLI skip must be visible (hook::emit_skip_notice /" + echo "hook::emit_system_message / a stderr write) or carry a documented" + echo "quiet classification: '# silent-skip-ok: ' at the site." + } >&2 + exit 1 +fi +echo "No silent prerequisite skips found in hook entry scripts." diff --git a/scripts/check-silent-skips.test.sh b/scripts/check-silent-skips.test.sh new file mode 100755 index 000000000..0aa0bc517 --- /dev/null +++ b/scripts/check-silent-skips.test.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# Unit tests for check-silent-skips.sh. Builds a tiny synthetic plugins/ tree +# per scenario in a temp dir and invokes the script against it directly -- the +# script's own `cd "$(dirname "$0")/.."` makes this work unmodified: copy it +# to /scripts/ and it scans /plugins/*/hooks/*.sh. +# shellcheck disable=SC2016 # fixture bodies are literal hook-script code passed in single quotes; expansion is never wanted +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SELF_DIR/check-silent-skips.sh" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +new_fixture() { + local dir + dir="$(mktemp -d)" + mkdir -p "$dir/scripts" "$dir/plugins" + cp "$SCRIPT" "$dir/scripts/check-silent-skips.sh" + chmod +x "$dir/scripts/check-silent-skips.sh" + printf '%s' "$dir" +} + +# hook_file +hook_file() { + local fixture="$1" plugin="$2" name="$3" content="$4" + mkdir -p "$fixture/plugins/$plugin/hooks" + printf '%s\n' "$content" >"$fixture/plugins/$plugin/hooks/$name" +} + +run_check() ( + cd "$1" && bash scripts/check-silent-skips.sh +) + +# --- same-line silent skip fails ------------------------------------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'command -v jq >/dev/null 2>&1 || exit 0' +if out="$(run_check "$f" 2>&1)"; then + fail "same-line silent skip should fail, got success: $out" +else + if echo "$out" | grep -q "SILENT SKIP: plugins/alpha/hooks/check.sh:1"; then + ok "same-line silent skip fails with file:line" + else + fail "expected SILENT SKIP with file:line, got: $out" + fi +fi +rm -rf "$f" + +# --- same-line skip via skip-named helper fails ---------------------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'command -v pwsh >/dev/null 2>&1 || emit_skipped' +if run_check "$f" >/dev/null 2>&1; then + fail "skip-named-helper guard should fail" +else + ok "skip-named-helper guard (|| emit_skipped) fails" +fi +rm -rf "$f" + +# --- same-line skip with annotation on the line above passes --------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh '# silent-skip-ok: output discarded by design +command -v jq >/dev/null 2>&1 || exit 0' +if out="$(run_check "$f" 2>&1)"; then + ok "annotated same-line skip passes" +else + fail "annotated same-line skip should pass, got: $out" +fi +rm -rf "$f" + +# --- annotation at the top of a multi-line comment block passes ------------ +f="$(new_fixture)" +hook_file "$f" alpha check.sh '# silent-skip-ok: fire-and-forget sink — output discarded by the producer, +# so no notice channel exists here. +command -v jq >/dev/null 2>&1 || exit 0' +if run_check "$f" >/dev/null 2>&1; then + ok "annotation anywhere in the comment block above the guard passes" +else + fail "multi-line comment-block annotation should pass" +fi +rm -rf "$f" + +# --- an annotation does not leak past intervening code --------------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh '# silent-skip-ok: covers only the next guard +command -v jq >/dev/null 2>&1 || exit 0 +INPUT=$(cat) +command -v gh >/dev/null 2>&1 || exit 0' +if out="$(run_check "$f" 2>&1)"; then + fail "annotation should not sanction a later guard, got success: $out" +else + if echo "$out" | grep -q "check.sh:4"; then + ok "annotation does not leak past intervening code" + else + fail "expected only line 4 flagged, got: $out" + fi +fi +rm -rf "$f" + +# --- same-line skip with annotation on the same line passes ---------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'command -v jq >/dev/null 2>&1 || exit 0 # silent-skip-ok: sink is fire-and-forget' +if run_check "$f" >/dev/null 2>&1; then + ok "same-line annotation passes" +else + fail "same-line annotation should pass" +fi +rm -rf "$f" + +# --- silent block skip fails ----------------------------------------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi' +if out="$(run_check "$f" 2>&1)"; then + fail "silent block skip should fail, got success: $out" +else + if echo "$out" | grep -q "silent block skip"; then + ok "silent block skip fails" + else + fail "expected silent block skip message, got: $out" + fi +fi +rm -rf "$f" + +# --- block skip with a stderr notice passes -------------------------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'if ! command -v jq >/dev/null 2>&1; then + echo "alpha: jq not found — advisory disabled" >&2 + exit 0 +fi' +if run_check "$f" >/dev/null 2>&1; then + ok "block skip with stderr notice passes" +else + fail "block skip with stderr notice should pass" +fi +rm -rf "$f" + +# --- block skip with a sanctioned notice call passes ----------------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'if ! command -v actionlint >/dev/null 2>&1; then + hook::emit_skip_notice PostToolUse "alpha: actionlint not found — lint skipped" + exit 0 +fi' +if run_check "$f" >/dev/null 2>&1; then + ok "block skip with hook::emit_skip_notice passes" +else + fail "block skip with hook::emit_skip_notice should pass" +fi +rm -rf "$f" + +# --- block skip with an in-block annotation passes ------------------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'if ! command -v jq >/dev/null 2>&1; then + # silent-skip-ok: not-applicable classification, CI is the gate + exit 0 +fi' +if run_check "$f" >/dev/null 2>&1; then + ok "block skip with in-block annotation passes" +else + fail "block skip with in-block annotation should pass" +fi +rm -rf "$f" + +# --- nested if inside the guard block is tracked to the right fi ----------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'if ! command -v jq >/dev/null 2>&1; then + if [[ -n "${VERBOSE:-}" ]]; then + true + fi + exit 0 +fi' +if run_check "$f" >/dev/null 2>&1; then + fail "nested-if silent block should still fail" +else + ok "nested-if silent block still fails (fi matching is depth-aware)" +fi +rm -rf "$f" + +# --- non-skip guards are never flagged ------------------------------------- +f="$(new_fixture)" +hook_file "$f" alpha check.sh 'command -v cygpath >/dev/null 2>&1 || return 1 +command -v "$bin" >/dev/null 2>&1 || continue +if command -v perl >/dev/null 2>&1; then + true +fi' +if out="$(run_check "$f" 2>&1)"; then + ok "return 1 / continue / positive-form guards never flagged" +else + fail "non-skip guards should pass, got: $out" +fi +rm -rf "$f" + +# --- hook-utils.sh copies and test files are excluded ---------------------- +f="$(new_fixture)" +hook_file "$f" alpha hook-utils.sh 'command -v jq >/dev/null 2>&1 || return 0' +hook_file "$f" alpha check.test.sh 'command -v jq >/dev/null 2>&1 || exit 0' +if out="$(run_check "$f" 2>&1)"; then + ok "hook-utils.sh and *.test.sh are excluded from the scan" +else + fail "excluded files should not be scanned, got: $out" +fi +rm -rf "$f" + +# --- an empty plugins tree passes ------------------------------------------ +f="$(new_fixture)" +if run_check "$f" >/dev/null 2>&1; then + ok "empty tree passes" +else + fail "empty tree should pass" +fi +rm -rf "$f" + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ "$FAIL" -eq 0 ]]