diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 974338b75..650ea6e95 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.56.16", + "version": "0.56.17", "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used: a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface: every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json: full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces, namely built-in CLI commands, bundled skills, plugin-backed built-ins, and session-provided skills, against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry from the OTEL store, the collector, the per-session hook event log and hook-event JSONL, and ccusage, with trend reports, a per-session report of what fired, what was blocked and the event timeline, 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), morning-brief (read-only gh-based operator morning view: queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action, an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures. The last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared 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 fef81b5f0..6cf2a736c 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,13 @@ 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.56.17] + +### Changed + +- hook-failure-audit.sh audits only the transcript lines appended since the last Stop, keyed on a per-session cursor kept beside the warning marker. A turn with no new failure record now creates no process at all: measured by job-object accounting on Windows Git Bash at 3 process creations against a 3-creation harness floor (`bash -c`, `env`, `bash`), where it previously took 10. The count is the record because wall clock on that host drifts several-fold within an hour; across two runs the same turn measured 0.36-1.38 s before against 0.23-0.36 s after. The first Stop of a session keeps the tail cap and costs one `wc -lc`, which answers both the byte count the cap decision needs and the line count the cursor starts from, for 5 creations against 10, or 7 on the one session that first creates the data directory. The payload fields now ride on `hook::buffer_stdin_to`, which fuses the library's validation probe into the field read and answers both from the builtin parser. A turn that DOES carry a failure record reports exactly what it reported before, byte for byte. A cursor that is missing, malformed, pruned, ahead of a shortened transcript, or recorded against a different transcript_path rescans from the start, and rescanning cannot re-warn because the marker still decides that. +- The cursor line count is now accepted only as a canonical decimal (no leading zero, at most 15 digits); a value like `08` used to pass the old `^[0-9]+$` check and then fail bash's octal-reading `((...))` arithmetic, printing shell diagnostics on Stop instead of running either scan path. + ## [0.56.16] ### Changed diff --git a/plugins/claude-ops/hooks/hook-failure-audit.sh b/plugins/claude-ops/hooks/hook-failure-audit.sh index 57573a09b..d70fd4976 100755 --- a/plugins/claude-ops/hooks/hook-failure-audit.sh +++ b/plugins/claude-ops/hooks/hook-failure-audit.sh @@ -21,9 +21,17 @@ # PreToolUse/PostToolUse, for the cost rationale `guard_launch_monitor.py` and # ADR 0004 (D-12) record: a failure record is already in the transcript by the # time the turn ends, so once-per-turn cadence catches it as promptly as -# once-per-tool-call would at a fraction of the invocation count. The read is -# bounded (tail cap, truncated first line dropped) so per-turn cost is O(cap), -# not O(session length). +# once-per-tool-call would at a fraction of the invocation count. +# +# The scan is INCREMENTAL, keyed on a per-session cursor holding the number of +# transcript lines already audited. The transcript is the session's own JSONL +# and grows every turn, so no mtime or size sentinel on the FILE can mean +# "nothing new to audit" — the cheap signal has to be about the appended +# content. A Stop reads only the lines past the cursor, with `mapfile` and the +# shell's own pattern match, and a turn whose new lines carry no candidate exits +# having spawned no process at all. The cold scan (first Stop of a session, or a +# reset) keeps the tail cap so that one unbounded read stays O(cap). See the +# cursor block below for the file's shape and its failure modes. # # Matching is STRUCTURAL, never substring: a record counts only when the # top-level `.type == "attachment"` and `.attachment.type == @@ -63,16 +71,25 @@ hook::check_enabled "HOOK_FAILURE_AUDIT" START=${EPOCHREALTIME:-} -hook::buffer_stdin_to INPUT || exit 0 +# The payload fields are read by the SAME call that buffers stdin. Passing +# filters to hook::buffer_stdin_to fuses the library's `jq -e .` validation +# probe into the field read, and hook::jq_fields answers a well-formed payload's +# plain-string fields with the library's builtin parser — so a Stop envelope +# costs no process at all, where the unfused pair cost a fork and a jq exec. +# +# The fused call is also why hook::require_jq comes AFTER it rather than before: +# without jq the library returns an EMPTY field array and still reports success, +# and reading `${HOOK_JQ_FIELDS[0]}` from it under `set -u` would kill the hook +# with an unbound-variable error instead of failing open. The gate runs first, +# and the cardinality check behind it is what makes the array read safe. +hook::buffer_stdin_to INPUT '.transcript_path' '.session_id' || exit 0 # Advisory finding -> fail open, with the standard once-per-session notice. hook::require_jq Stop claude-ops "$INPUT" -# Both payload fields in ONE jq process (hook::jq_fields), not two: a jq spawn is -# a process, and two hook::jq_field calls read the same envelope twice for it. -# An absent field arrives as the empty string here rather than as a non-zero -# return, so each guard below is spelled out instead of riding on `||`. -hook::jq_fields "$INPUT" '.transcript_path' '.session_id' || exit 0 +# An absent field arrives as the empty string rather than as a non-zero return, +# so each guard below is spelled out instead of riding on `||`. +((${#HOOK_JQ_FIELDS[@]} == 2)) || exit 0 TRANSCRIPT="${HOOK_JQ_FIELDS[0]}" [[ -n "$TRANSCRIPT" && -f "$TRANSCRIPT" ]] || exit 0 SESSION="${HOOK_JQ_FIELDS[1]}" @@ -83,22 +100,91 @@ SESSION_ID="" [[ "$SESSION" != "no-session" && "$SESSION" =~ ^[A-Za-z0-9._-]+$ ]] && SESSION_ID="$SESSION" SESSION="${SESSION//[^A-Za-z0-9_-]/-}" -# Bounded tail read: cost stays O(cap) regardless of transcript growth. When -# the cap truncates, the first in-window line is likely partial — drop it, as -# guard_launch_monitor.py does. The override exists for the contract test. +# `mapfile` is Bash 4.0+ and these hooks document 3.2+ support (hook-utils.sh). +# Without it there is no builtin line reader, so the cursor is simply not +# available and every Stop takes the cold path's grep pre-filter — the work this +# hook has always done, never a silent skip. +HAVE_MAPFILE=0 +((BASH_VERSINFO[0] >= 4)) && HAVE_MAPFILE=1 + +# CURSOR: how many transcript lines this session has already audited. The file +# is "\n\n", beside the warning marker, under the same +# ${CLAUDE_PLUGIN_DATA} home and swept by the same 7-day prune. +# +# Every failure mode resolves toward RESCANNING, never toward silence — the same +# doctrine the marker follows: +# - no marker home, an unreadable or malformed cursor -> 0, a full scan +# - a different transcript_path -> 0, this session was handed another file +# - fewer lines present than the cursor -> 0, the transcript shrank or was +# replaced +# - a cursor pruned mid-session, or a Bash without `mapfile` -> 0 +# - a non-canonical decimal (a leading zero, or over 15 digits) -> 0 +# Rescanning cannot re-warn: the (hookName, command) marker below is what +# decides that, and it is unchanged. +# +# The cursor counts COMPLETE lines only. A final line with no newline is still +# scanned this turn — skipping it could hide a record the full scan would have +# surfaced — but it is not counted, so the next Stop reads it again once the +# harness has finished writing it. +MARKER_DIR="" +CURSOR_FILE="" +CURSOR=0 +if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then + MARKER_DIR="${CLAUDE_PLUGIN_DATA}/hook-failure-audit" + # `-d` first: after the first turn the directory always exists, and `mkdir -p` + # on an existing directory is a whole process to reach the same no-op. A + # directory that exists but is unwritable reaches the writes below and fails + # there, which is the degrade-toward-rescanning path. + if [[ -d "$MARKER_DIR" ]] || mkdir -p "$MARKER_DIR" 2>/dev/null; then + if ((HAVE_MAPFILE)); then + CURSOR_FILE="$MARKER_DIR/${SESSION}.cursor" + CURSOR_LINES="" + CURSOR_PATH="" + # Group redirect, not `$(/dev/null` is written BEFORE the + # input redirect because redirections apply left to right — after it, a + # failed open still prints its diagnostic, and a Stop hook's stderr is + # user-visible output. + [[ -f "$CURSOR_FILE" ]] && + { + IFS= read -r CURSOR_LINES + IFS= read -r CURSOR_PATH + } 2>/dev/null <"$CURSOR_FILE" + CURSOR_LINES="${CURSOR_LINES%$'\r'}" + CURSOR_PATH="${CURSOR_PATH%$'\r'}" + # Canonical decimal only, capped at 15 digits (far below 2^63): a leading + # zero such as "08" passes a bare `[0-9]+` test but bash's `((...))` + # reads a leading zero as octal and errors on 8/9, and an uncapped digit + # string can wrap in arithmetic. Both are rejected here, not coerced with + # `10#`. A rejected value falls through to the CURSOR=0 cold path below, + # the same outcome every other malformed cursor already gets. + [[ "$CURSOR_LINES" =~ ^(0|[1-9][0-9]{0,14})$ && "$CURSOR_PATH" == "$TRANSCRIPT" ]] && + CURSOR="$CURSOR_LINES" + fi + else + MARKER_DIR="" + fi +fi + +# Advanced only once the lines it covers have been DISPOSED of: no candidate, an +# empty structural selection, nothing left unwarned, or a warning emitted. A jq +# failure leaves the cursor where it stood so those lines are audited again. +# `2>/dev/null` ahead of the output redirect, as on the read above: an unwritable +# marker home must degrade to rescanning silently, not print at the user. +SCANNED=0 +cursor_advance() { + [[ -n "$CURSOR_FILE" ]] || return 0 + printf '%s\n%s\n' "$SCANNED" "$TRANSCRIPT" 2>/dev/null >"$CURSOR_FILE" || : +} + +# Bounded COLD read: the first Stop of a session (or a reset) is the one read +# the cursor cannot bound, so the byte cap stays. When the cap truncates, the +# first in-window line is likely partial — drop it, as guard_launch_monitor.py +# does. The override exists for the contract test. TAIL_BYTES="${HOOK_FAILURE_AUDIT_TAIL_BYTES:-2000000}" -# `wc -c -- `, not `wc -c `: bash runs the command of a command -# substitution in the substitution's own subshell and skips the extra fork ONLY -# when that command carries no redirection of its own, so the `<` bought a whole -# second process for one byte count (#3779). Naming the file instead adds a -# filename column, which `read` drops along with the leading padding some `wc` -# builds emit. The `2>/dev/null` rides on the surrounding single-command group, -# where it silences the same stream without re-arming the fork. -SIZE="" -{ read -r SIZE _ < <(wc -c -- "$TRANSCRIPT"); } 2>/dev/null -[[ -n "$SIZE" ]] || exit 0 -# grep is a cheap pre-filter only; the structural jq selection decides. The +# The pre-filter is a cheap candidate test only; the structural jq selection +# decides. The # no-match common case exits on the pre-filter's emptiness, before paying for # the jq spawn (an empty stream produced the same silent exit via "[]"). # `fromjson?` skips unparsable lines instead of aborting the stream. @@ -152,26 +238,79 @@ SIZE="" # group visible, and the message flags are computed from those counts, never # from a single collapsed value. # -# The window is read INTO grep, not through a `read_window` helper: a function -# call inside `$( )` is a second subshell on top of the substitution's own, and -# `cat -- file | grep` paid a further process to hand grep bytes it can open -# itself. Under the cap, grep now opens the transcript directly and the whole -# pre-filter is one process. Over the cap the pipeline is unchanged — the -# truncated first in-window line is likely partial and `sed '1d'` drops it, as -# guard_launch_monitor.py does — and its `2>/dev/null` stays exactly where it -# was, on `tail`, because a pipeline element forks either way and moving the -# redirect out would newly silence sed and grep for no saving. -if ((SIZE > TAIL_BYTES)); then - RECORDS=$(tail -c "$TAIL_BYTES" -- "$TRANSCRIPT" 2>/dev/null | sed '1d' | - grep -F '"hook_non_blocking_error"') -else - # Group-scoped redirect: `grep … 2>/dev/null` inside the substitution would - # cost the extra fork the file-argument form just saved (#3779). The group - # holds one command, so nothing beyond grep's own stderr is silenced — and - # that stream was already discarded before, by `cat`'s own `2>/dev/null`. - { RECORDS=$(grep -F '"hook_non_blocking_error"' -- "$TRANSCRIPT"); } 2>/dev/null +# `[[ $line == *needle* ]]` is `grep -F` on one line, and it is the same fixed +# string: identical selection, no process. `mapfile` is read WITHOUT `-t` so +# every line keeps its newline, which makes the joined candidates byte-identical +# to what `$(grep …)` produced — and makes a final line with no newline visible, +# the one line the cursor must not count. +NEEDLE='"hook_non_blocking_error"' +RECORDS="" +LINES=() +N=0 +scan_lines() { # + local i + for ((i = $1; i < N; i++)); do + [[ "${LINES[i]}" == *"$NEEDLE"* ]] && RECORDS+="${LINES[i]}" + done + RECORDS="${RECORDS%$'\n'}" +} + +if ((CURSOR > 0)); then + # One line BEFORE the cursor is read as an anchor, so the same read that + # fetches the new lines also proves the transcript still HAS that many: an + # empty result means it shrank or was replaced, and no second pass over the + # file is needed to find that out. `-s` discards the skipped lines rather than + # storing them, and `mapfile` is a builtin — no subshell, no exec. + mapfile -s $((CURSOR - 1)) LINES <"$TRANSCRIPT" 2>/dev/null + N=${#LINES[@]} + if ((N == 0)); then + CURSOR=0 + else + SCANNED=$((CURSOR - 1 + N)) + [[ "${LINES[N - 1]}" == *$'\n' ]] || SCANNED=$((SCANNED - 1)) + scan_lines 1 + fi +fi + +if ((CURSOR == 0)); then + # `wc -lc -- ` answers BOTH cold-path questions in one process: the byte + # count the cap decision needs, and the line count the cursor starts from + # (newlines, so a trailing partial line is excluded for free). Naming the file + # rather than `< file` is what keeps it one process: bash runs the command of + # a command substitution in the substitution's own subshell and skips the + # extra fork ONLY when that command carries no redirection of its own (#3779). + # `read` drops the filename column along with the leading padding some `wc` + # builds emit, and the `2>/dev/null` rides on the surrounding single-command + # group, where it silences the same stream without re-arming that fork. + SIZE="" + { read -r SCANNED SIZE _ < <(wc -lc -- "$TRANSCRIPT"); } 2>/dev/null + [[ "$SCANNED" =~ ^[0-9]+$ && -n "$SIZE" ]] || exit 0 + if ((SIZE > TAIL_BYTES)); then + # Over the cap the pipeline is unchanged — the truncated first in-window + # line is likely partial and `sed '1d'` drops it, as guard_launch_monitor.py + # does — and its `2>/dev/null` stays exactly where it was, on `tail`, + # because a pipeline element forks either way and moving the redirect out + # would newly silence sed and grep for no saving. Lines before the window + # are not read here and never were; the cursor simply stops the next Stop + # from re-deciding that. + RECORDS=$(tail -c "$TAIL_BYTES" -- "$TRANSCRIPT" 2>/dev/null | sed '1d' | + grep -F "$NEEDLE") + elif ((HAVE_MAPFILE)); then + mapfile LINES <"$TRANSCRIPT" 2>/dev/null + N=${#LINES[@]} + scan_lines 0 + else + # Group-scoped redirect: `grep … 2>/dev/null` inside the substitution would + # cost the extra fork the file-argument form just saved (#3779). The group + # holds one command, so nothing beyond grep's own stderr is silenced. + { RECORDS=$(grep -F "$NEEDLE" -- "$TRANSCRIPT"); } 2>/dev/null + fi fi -[[ -n "$RECORDS" ]] || exit 0 +LINES=() +[[ -n "$RECORDS" ]] || { + cursor_advance + exit 0 +} # `printf | jq` and NOT a here-string, even though the pipeline costs a process # the here-string would not. What is known, stated as known: hook::jq_field in # the shared library documents this hazard and refuses the here-string form for @@ -207,28 +346,29 @@ SUMMARY=$(printf '%s' "$RECORDS" | ambiguousCount: (map(select(.class == "ambiguous")) | length), completedCount: (map(select(.class == "completed")) | length), exitCode: last.exitCode, stderr: last.stderr})' 2>/dev/null) -[[ -n "$SUMMARY" && "$SUMMARY" != "[]" ]] || exit 0 +# An EMPTY $SUMMARY is jq failing, not a clean selection: leave the cursor where +# it stood so the same lines are audited again. `[]` is a document, and one that +# disposes of them. +[[ -n "$SUMMARY" ]] || exit 0 +[[ "$SUMMARY" != "[]" ]] || { + cursor_advance + exit 0 +} # Once per session per hook name. Markers live under ${CLAUDE_PLUGIN_DATA} -# (survives plugin updates); stale sessions' markers are pruned after 7 days. -# Any bookkeeping failure leaves WARNED empty, so everything found is treated -# as new — re-warn, never suppress. +# (survives plugin updates) in the directory resolved above; stale sessions' +# markers and cursors are pruned together after 7 days. Any bookkeeping failure +# leaves WARNED empty, so everything found is treated as new — re-warn, never +# suppress. MARKER="" WARNED="" -if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then - MARKER_DIR="${CLAUDE_PLUGIN_DATA}/hook-failure-audit" - # `-d` first: after the first warned turn the directory always exists, and - # `mkdir -p` on an existing directory is a whole process to reach the same - # no-op. A directory that exists but is unwritable fell through `mkdir -p` - # successfully before too, and still fails at the marker write below. - if [[ -d "$MARKER_DIR" ]] || mkdir -p "$MARKER_DIR" 2>/dev/null; then - find "$MARKER_DIR" -type f -mtime +7 -delete 2>/dev/null - MARKER="$MARKER_DIR/${SESSION}" - # `$(/dev/null - fi +if [[ -n "$MARKER_DIR" ]]; then + find "$MARKER_DIR" -type f -mtime +7 -delete 2>/dev/null + MARKER="$MARKER_DIR/${SESSION}" + # `$(/dev/null fi # Marker lines are "\t" fingerprints. `rtrimstr("\r")` on @@ -238,7 +378,11 @@ fi NEW=$(jq -cn --argjson summary "$SUMMARY" --arg warned "$WARNED" ' ($warned | split("\n") | map(rtrimstr("\r")) | map(select(length > 0))) as $seen | [$summary[] | select((.hookName + " " + .command) as $k | $seen | index($k) | not)]') -[[ -n "$NEW" && "$NEW" != "[]" ]] || exit 0 +[[ -n "$NEW" ]] || exit 0 +[[ "$NEW" != "[]" ]] || { + cursor_advance + exit 0 +} TOTAL=$(jq -rn --argjson new "$NEW" '[$new[].count] | add') @@ -304,6 +448,11 @@ fi hook::emit_system_message "$MSG" +# The lines are disposed of the moment the warning is out. A marker write that +# fails after this point costs nothing: those registrations were warned about +# once, which is the contract. +cursor_advance + # Record what was warned about before telemetry: the warning is the contract, # the envelope is best-effort. # `tr -d '\r'` is gone, not its effect: the CRs come from a Windows jq build diff --git a/plugins/claude-ops/hooks/hook-failure-audit.test.sh b/plugins/claude-ops/hooks/hook-failure-audit.test.sh index ec85fb472..a7baee94f 100755 --- a/plugins/claude-ops/hooks/hook-failure-audit.test.sh +++ b/plugins/claude-ops/hooks/hook-failure-audit.test.sh @@ -289,6 +289,129 @@ OUT6=$(run_hook "$T3" "$DATA3" HOOK_FAILURE_AUDIT_TAIL_BYTES=8000) assert_contains "in-window failure reported" "$OUT6" "PreToolUse:InWindow" assert_absent "out-of-window failure not read" "$OUT6" "PreToolUse:OutOfWindow" +# --- Incremental scan: the cursor -------------------------------------------- +# The cursor records how many transcript lines a session has already audited, so +# a later Stop reads only what was appended. Four properties are asserted: a +# turn with nothing new spawns nothing at all, a failure appended past the +# cursor produces EXACTLY the message a full rescan produces, and both a shorter +# transcript and a different transcript_path reset the cursor rather than +# skipping lines that were never audited. + +# A PATH shim that fails loudly instead of doing the work. `command -v jq` still +# succeeds — that is what lets the library's builtin field parser proceed — so +# any surviving spawn reaches a shim and breaks silence. +SHIM="$TEST_TMPDIR/shim" +mkdir -p "$SHIM" +for PROG in jq grep wc tail sed find cat mkdir; do + printf '#!/bin/sh\necho "SPAWNED %s" >&2\nexit 99\n' "$PROG" >"$SHIM/$PROG" + chmod +x "$SHIM/$PROG" +done + +T_CUR="$TEST_TMPDIR/cursor.jsonl" +DATA_CUR="$TEST_TMPDIR/data-cursor" +{ + for _ in {1..6}; do + printf '{"type":"assistant","message":{"content":[{"type":"text","text":"fine"}]},"uuid":"c","session_id":"s"}\n' + done + failure_record "PreToolUse:Bash" "first-registration.sh" +} >"$T_CUR" +OUT_C1=$(run_hook "$T_CUR" "$DATA_CUR") +assert_contains "cursor: first Stop warns" "$OUT_C1" "first-registration.sh" +assert_eq "cursor: file records the audited line count" "7" \ + "$(head -1 "$DATA_CUR/hook-failure-audit/test-session.cursor")" +assert_eq "cursor: file records the transcript path" "$T_CUR" \ + "$(sed -n 2p "$DATA_CUR/hook-failure-audit/test-session.cursor")" + +# Nothing appended: the second Stop must reach its exit with no process at all. +OUT_C2=$(run_hook "$T_CUR" "$DATA_CUR" PATH="$SHIM:$PATH") +assert_silent "cursor: unchanged transcript -> silent, nothing spawned" "$OUT_C2" + +# Benign lines only: still nothing to hand to jq, still no process. +for _ in {1..20}; do + printf '{"type":"assistant","message":{"content":[{"type":"text","text":"fine"}]},"uuid":"c","session_id":"s"}\n' >>"$T_CUR" +done +OUT_C3=$(run_hook "$T_CUR" "$DATA_CUR" PATH="$SHIM:$PATH") +assert_silent "cursor: appended benign lines -> silent, nothing spawned" "$OUT_C3" + +# A failure appended past the cursor: byte-identical to what a full rescan of +# the same transcript, against the same marker state, produces. DATA_FULL is a +# copy of the incremental state with only the cursor removed, so the two runs +# differ in nothing but how much of the transcript they read. +failure_record "SessionStart" "second-registration.mjs" >>"$T_CUR" +DATA_FULL="$TEST_TMPDIR/data-cursor-full" +rm -rf "$DATA_FULL" +cp -r "$DATA_CUR" "$DATA_FULL" +rm -f "$DATA_FULL/hook-failure-audit/test-session.cursor" +OUT_INC=$(run_hook "$T_CUR" "$DATA_CUR") +OUT_FULL=$(run_hook "$T_CUR" "$DATA_FULL") +assert_contains "cursor: appended failure is reported" "$OUT_INC" "second-registration.mjs" +assert_eq "cursor: incremental output equals a full rescan's" "$OUT_FULL" "$OUT_INC" +assert_absent "cursor: the already-warned registration stays muted" "$OUT_INC" "first-registration.sh" + +# A SHORTER transcript resets the cursor. Without the reset the read starts past +# the end of the file and the new record is never seen. +T_SHORT="$TEST_TMPDIR/cursor.jsonl" +failure_record "PreToolUse:Shrunk" "after-truncation.sh" >"$T_SHORT" +OUT_C4=$(run_hook "$T_SHORT" "$DATA_CUR") +assert_contains "cursor: a shorter transcript rescans from the start" "$OUT_C4" "after-truncation.sh" + +# A DIFFERENT transcript_path resets it too. This file is LONGER than the stored +# cursor and carries its failure record BEFORE it, so only the path check can +# save the record: a stale line count would skip straight past it. +T_OTHER="$TEST_TMPDIR/cursor-other.jsonl" +DATA_OTHER="$TEST_TMPDIR/data-cursor-other" +{ + for _ in {1..30}; do + printf '{"type":"assistant","message":{"content":[{"type":"text","text":"fine"}]},"uuid":"c","session_id":"s"}\n' + done +} >"$T_OTHER" +run_hook "$T_OTHER" "$DATA_OTHER" >/dev/null # cursor: 30 lines of this path +printf '%s\n' "$(failure_record 'PreToolUse:Moved' 'other-transcript.sh')" \ + >"$TEST_TMPDIR/cursor-other-2.jsonl" +for _ in {1..40}; do + printf '{"type":"assistant","message":{"content":[{"type":"text","text":"fine"}]},"uuid":"c","session_id":"s"}\n' >>"$TEST_TMPDIR/cursor-other-2.jsonl" +done +OUT_C5=$(run_hook "$TEST_TMPDIR/cursor-other-2.jsonl" "$DATA_OTHER") +assert_contains "cursor: a different transcript_path rescans from the start" \ + "$OUT_C5" "other-transcript.sh" + +# A non-canonical decimal cursor (a leading zero, or a digit string long enough +# to wrap) must fall through to CURSOR=0 exactly like any other malformed +# cursor: no shell diagnostic, a cold scan of the whole transcript, and a +# canonical value written back afterward. `08`/`09` pass a bare `^[0-9]+$` +# test and then fail bash's octal-reading `((...))` with a "value too great +# for base" diagnostic on stderr; a long-enough digit string wraps silently in +# arithmetic instead of erroring, which is the more dangerous case. +T_LEADING_ZERO="$TEST_TMPDIR/cursor-leading-zero.jsonl" +DATA_LEADING_ZERO="$TEST_TMPDIR/data-cursor-leading-zero" +failure_record "PreToolUse:LeadingZero" "leading-zero.sh" >"$T_LEADING_ZERO" +mkdir -p "$DATA_LEADING_ZERO/hook-failure-audit" +printf '08\n%s\n' "$T_LEADING_ZERO" \ + >"$DATA_LEADING_ZERO/hook-failure-audit/test-session.cursor" +OUT_C6=$(run_hook "$T_LEADING_ZERO" "$DATA_LEADING_ZERO") +assert_absent "cursor: leading zero prints no shell diagnostic" "$OUT_C6" "value too great for base" +assert_contains "cursor: leading zero falls back to a cold scan" "$OUT_C6" "leading-zero.sh" +assert_eq "cursor: leading zero is rewritten to a canonical value" "1" \ + "$(head -1 "$DATA_LEADING_ZERO/hook-failure-audit/test-session.cursor")" + +T_OVERSIZED="$TEST_TMPDIR/cursor-oversized.jsonl" +DATA_OVERSIZED="$TEST_TMPDIR/data-cursor-oversized" +failure_record "PreToolUse:Oversized" "oversized-cursor.sh" >"$T_OVERSIZED" +mkdir -p "$DATA_OVERSIZED/hook-failure-audit" +printf '%s\n%s\n' "11111111111111111111" "$T_OVERSIZED" \ + >"$DATA_OVERSIZED/hook-failure-audit/test-session.cursor" +OUT_C7=$(run_hook "$T_OVERSIZED" "$DATA_OVERSIZED") +# bash wraps an overlong digit string silently rather than erroring, so the +# diagnostic to rule out here is any stray output line, not one exact string. +if [[ "$OUT_C7" == *$'\n'* ]]; then + bad "cursor: 20-digit cursor prints no shell diagnostic: unexpected extra line(s) in: $OUT_C7" +else + ok "cursor: 20-digit cursor prints no shell diagnostic" +fi +assert_contains "cursor: 20-digit cursor falls back to a cold scan" "$OUT_C7" "oversized-cursor.sh" +assert_eq "cursor: 20-digit cursor is rewritten to a canonical value" "1" \ + "$(head -1 "$DATA_OVERSIZED/hook-failure-audit/test-session.cursor")" + # --- Kill switch ------------------------------------------------------------- OUT7=$(run_hook "$T2" "$TEST_TMPDIR/data-kill" CLAUDE_PLUGIN_OPTION_HOOK_FAILURE_AUDIT_ENABLED=false) RC7=$? @@ -346,23 +469,24 @@ fi # `-ff` writes one file per pid, so no syscall line is ever split across an # / pair where a naive grep would silently undercount. # -# The common path is a turn with NO hook failure recorded, under the tail cap. -# Budget: -# 1 wc the file size the tail-cap decision needs -# 1 grep the pre-filter, reading the transcript directly -# 2 jq both inside hook-utils.sh (buffer_stdin's validation probe, and the -# single hook::jq_fields payload read) — a synced library this plugin -# does not own -# 0 cat the removed process: `cat -- file | grep` handed grep bytes it can -# open itself, through a `read_window` function call that was a -# second subshell on top of the substitution's own +# TWO paths are measured, because the cursor splits them. The COLD path is the +# first Stop of a session, under the tail cap: +# 1 wc `wc -lc`: the byte count the cap decision needs and the line count +# the cursor starts from, in one process +# 1 mkdir the marker/cursor directory, created once per data home +# 0 jq the payload fields ride on hook::buffer_stdin_to, and the library +# answers a plain-string field with its builtin parser +# 0 grep the pre-filter is `[[ $line == *needle* ]]` over a `mapfile` read +# 0 cat, 0 tail, 0 sed +# The WARM path — every later Stop, which is the cadence this hook actually runs +# at — reads only the appended lines and creates NOTHING. # -# MUTATION-CHECKED: moving a silenced redirect back inside its substitution — -# `SIZE=$(wc -c <"$TRANSCRIPT" 2>/dev/null)`, or -# `RECORDS=$(grep -F … -- "$TRANSCRIPT" 2>/dev/null)` — adds a fork with NO new -# exec, leaves every behavioural assertion above green, and trips the creation -# ceiling below. That is what the creation count is for; execve alone is blind -# to it. +# MUTATION-CHECKED: moving a silenced redirect back inside its substitution +# (`SIZE=$(wc -lc <"$TRANSCRIPT" 2>/dev/null)`) adds a fork with NO new exec, +# leaves every behavioural assertion above green, and trips the cold creation +# ceiling below. Dropping the cursor write leaves every behavioural assertion +# green too, and trips the warm ceiling. That is what the creation count is for; +# execve alone is blind to both. TRACE_OK=1 command -v strace >/dev/null 2>&1 || TRACE_OK=0 if ((TRACE_OK)); then @@ -373,42 +497,50 @@ if ((TRACE_OK == 0)); then else TB="$TEST_TMPDIR/budget.jsonl" printf '{"type":"assistant","message":{"content":[{"type":"text","text":"all fine"}]},"uuid":"c","session_id":"s"}\n' >"$TB" - TRACE_PREFIX="$TEST_TMPDIR/budget-trace" - env CLAUDE_PLUGIN_DATA="$TEST_TMPDIR/data-budget" HOOK_TELEMETRY_SINK="" \ - strace -ff -qq -s 400 -e trace=clone,clone3,fork,vfork,execve -o "$TRACE_PREFIX" \ - bash "$HOOK" <<<"{\"session_id\":\"budget\",\"transcript_path\":\"$TB\",\"hook_event_name\":\"Stop\"}" \ - >/dev/null 2>&1 - TRACE_ALL="$TEST_TMPDIR/budget-trace.all" - cat "$TRACE_PREFIX".* >"$TRACE_ALL" 2>/dev/null - if [[ -s "$TRACE_ALL" ]]; then - ok "budget: the common path was traced" - else - bad "budget: no usable strace output captured" - fi + TRACE_ALL="" + trace_hook() { # + local prefix="$TEST_TMPDIR/$1" + env CLAUDE_PLUGIN_DATA="$TEST_TMPDIR/data-budget" HOOK_TELEMETRY_SINK="" \ + strace -ff -qq -s 400 -e trace=clone,clone3,fork,vfork,execve -o "$prefix" \ + bash "$HOOK" <<<"{\"session_id\":\"budget\",\"transcript_path\":\"$TB\",\"hook_event_name\":\"Stop\"}" \ + >/dev/null 2>&1 + TRACE_ALL="$prefix.all" + cat "$prefix".* >"$TRACE_ALL" 2>/dev/null + } # Every process creation the kernel saw, subshell forks included. - CREATIONS=$(grep -cE '^(clone|clone3|fork|vfork)\(' "$TRACE_ALL") + creations() { grep -cE '^(clone|clone3|fork|vfork)\(' "$TRACE_ALL"; } # Successful execs only: a PATH search emits failing execve calls that spawn # nothing. The `bash ` exec at the top is strace's own, not a cost of # the hook, and is excluded by matching the hook path in its argv — which is # why the trace runs with `-s 400`: at strace's 32-byte default that path is # abbreviated and the exclusion silently matches nothing. - EXECS=$(grep -E '^execve\(.*= 0$' "$TRACE_ALL" | grep -c -v -e "$HOOK") - PROGS=$(grep -E '^execve\(.*= 0$' "$TRACE_ALL" | grep -v -e "$HOOK" | - grep -oE '^execve\("[^"]+"' | sed 's|.*/||; s|"||' | sort | uniq -c | tr -s ' \n' ' ') + execs() { grep -E '^execve\(.*= 0$' "$TRACE_ALL" | grep -c -v -e "$HOOK"; } + progs() { + grep -E '^execve\(.*= 0$' "$TRACE_ALL" | grep -v -e "$HOOK" | + grep -oE '^execve\("[^"]+"' | sed 's|.*/||; s|"||' | sort | uniq -c | tr -s ' \n' ' ' + } prog_count() { grep -cE "^execve\\(\"[^\"]*/$1\"" "$TRACE_ALL"; } - if ((CREATIONS <= 9)); then - ok "budget: common path creates $CREATIONS processes (ceiling 9)" - else - bad "budget: common path creates $CREATIONS processes, ceiling is 9 —$PROGS" - fi - if ((EXECS <= 4)); then - ok "budget: common path execs $EXECS programs (ceiling 4)" + ceiling() { #