Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/conventions/hook-telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,10 @@ unwatched. This mirrors the standards-repo "adopt by copy" seam.
## Consuming (sink side)

A repo **subscribes** by setting `HOOK_TELEMETRY_SINK` (relative, committed in `settings.json`) to an
executable that reads one envelope on stdin and maps the common fields into its own store. The sink:
executable that reads one envelope on stdin and maps the common fields into its own store. The
envelope arrives as one JSON document; since the shared library's 2026-09-02 builtin emitter it is a
single compact line (`jq -c` shape, no trailing CR), where earlier producers wrote jq's
pretty-printed form. A sink must parse the document as JSON, never by line or byte layout. The sink:

- consumes only the common envelope unless it specifically handles a given `hook`'s `data`;
- ignores unknown keys and treats an unrecognized `status` as a catch-all (see Forward compatibility);
Expand Down
781 changes: 704 additions & 77 deletions lib/hook-utils.sh

Large diffs are not rendered by default.

280 changes: 272 additions & 8 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,30 +72,67 @@ else
fail "sink unset: unexpected output: $out"
fi

# --- Test 2: jq absent → fail-open (returns 0, no output) -------------------
# Make `command -v jq` genuinely fail by running with a PATH that contains no
# jq. A shell-function shadow does NOT exercise the guard: `command -v jq`
# reports a defined function as present, so the absence branch is never taken.
# emit_telemetry uses only shell builtins until its jq calls, so an empty PATH
# is sufficient. Scoped to the command so PATH/sink never leak into the suite.
# --- Test 2: jq absent → the envelope is still delivered ---------------------
# This case used to assert the opposite ("jq absent returns 0 with no output"):
# the envelope was built by a jq process, so no jq meant no telemetry. The
# envelope is now assembled with shell builtins and jq is only the fallback
# for a data object the builtin compactor cannot prove, so with jq gone from
# PATH a plain envelope must still reach the sink. Two probes:
# (a) PATH holds nothing at all. The sink stub uses an absolute shebang and
# only builtins, so the delivery cannot lean on any external program.
# (b) a shell function shadows `jq` and fails loudly; the envelope arriving
# intact proves the builtin path never invoked it.
# Both keep the fail-open contract on stdout: return 0, nothing on fd1.
EMPTY_BIN="$(mktemp -d)"
SINK_FILE2="$(mktemp)"
STUB2="$(mktemp)"
cat >"$STUB2" <<EOF
#!/bin/bash
while IFS= read -r line || [[ -n "\$line" ]]; do printf '%s\n' "\$line"; done >"$SINK_FILE2"
EOF
chmod +x "$STUB2"
# shellcheck disable=SC2030,SC2031
out_nojq=$(
export HOOK_TELEMETRY_SINK="cat"
export HOOK_TELEMETRY_SINK="$STUB2"
PATH="$EMPTY_BIN" hook::emit_telemetry "sample-hook" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"foo.py","findings":[]}' 2>/dev/null
wait
)
rc_nojq=$?
wait_for_sink "$SINK_FILE2" || true
rmdir "$EMPTY_BIN" 2>/dev/null || true
if [[ $rc_nojq -eq 0 ]]; then
ok "jq absent: returns 0"
else
fail "jq absent: expected 0, got $rc_nojq"
fi
if [[ -z "$out_nojq" ]]; then
ok "jq absent: no output"
ok "jq absent: nothing on stdout"
else
fail "jq absent: unexpected output: $out_nojq"
fi
if [[ -s "$SINK_FILE2" ]] && grep -q '"hook":"sample-hook","hook_event":"PostToolUse","status":"ok"' "$SINK_FILE2"; then
ok "jq absent (empty PATH): envelope still delivered by the builtin path"
else
fail "jq absent (empty PATH): envelope not delivered: $(cat "$SINK_FILE2" 2>/dev/null)"
fi
: >"$SINK_FILE2"
# shellcheck disable=SC2030,SC2031,SC2329 # subshell-local by design; jq is invoked through the lib
(
jq() {
echo "jq was invoked on the builtin path" >&2
return 127
}
export HOOK_TELEMETRY_SINK="$STUB2"
hook::emit_telemetry "sample-hook" "PostToolUse" "ok" "$EPOCHREALTIME" '{"tool":"Write","file":"foo.py","findings":[]}'
wait
) 2>"$SINK_FILE2.err"
wait_for_sink "$SINK_FILE2" || true
if [[ -s "$SINK_FILE2" ]] && ! grep -q 'jq was invoked' "$SINK_FILE2.err"; then
ok "jq shadowed: envelope delivered without invoking jq"
else
fail "jq shadowed: delivered=$([[ -s "$SINK_FILE2" ]] && echo yes || echo no) stderr=$(cat "$SINK_FILE2.err")"
fi
rm -f "$SINK_FILE2" "$SINK_FILE2.err" "$STUB2"

# --- Test 3: envelope shape matches schema (7 required common fields + data) --
SINK_FILE="$(mktemp)"
Expand Down Expand Up @@ -711,6 +748,233 @@ else
fail "json_escape: wrong residual-C0 handling: $(printf '%q' "$esc2")"
fi

# --- Test 14b: hook::json_escape_jq matches jq's own string escaping ----------
# The telemetry envelope's string fields are escaped by this function instead
# of by jq; the corpus covers every class jq treats specially (backslash,
# quote, the five short-form controls, the other C0 bytes, DEL) plus the
# classes it passes through (non-ASCII, slash).
corpus14b=(
'plain'
'he said "hi" \ path'
$'tab\tnl\ncr\rbs\bff\f' # portability-ok: bash ANSI-C backspace byte, not a GNU grep word boundary
$'esc\033one\001us\037del\177'
'slash/and é😀 and -- dash'
''
)
for s in "${corpus14b[@]}"; do
want=$(jq -cn --arg s "$s" '$s' | tr -d '\r')
got="\"$(hook::json_escape_jq "$s")\""
if [[ "$got" == "$want" ]]; then
ok "json_escape_jq: matches jq for $(printf '%q' "${s:0:24}")"
else
fail "json_escape_jq: bash=$got jq=$want"
fi
done

# --- Test 14c: hook::json_compact_to matches jq -c on jq's own output ---------
# The data object spliced into the envelope must be the bytes jq would print.
# jq -c output, jq -n (pretty) output and the compact literal fallbacks the
# hooks carry all compact identically; anything the compactor cannot prove
# (a \u or \/ escape, a raw control byte, a non-object, invalid JSON) returns 1
# so the caller runs jq instead.
compact_is() { # <desc> <input> <want>
local got=""
if hook::json_compact_to got "$2" && [[ "$got" == "$3" ]]; then
ok "json_compact_to: $1"
else
fail "json_compact_to ($1): got [$got] want [$3]"
fi
}
compact_refuses() { # <desc> <input>
local got=""
if hook::json_compact_to got "$2"; then
fail "json_compact_to ($1): accepted [$got], must fall back to jq"
else
ok "json_compact_to: $1 falls back"
fi
}
pretty14c=$(jq -n --arg tool Bash --arg subject 'git commit -m "x"' --arg form '' '{tool:$tool,subject:$subject,form:$form,n:[1,2,{a:null,b:true}],e:{},f:[]}')
compact_is "pretty jq -n object" "$pretty14c" "$(jq -c . <<<"$pretty14c" | tr -d '\r')"
compact_is "compact jq -c object is unchanged" '{"tool":"Write","file":"a b.py","findings":["x: [1] {2}"]}' '{"tool":"Write","file":"a b.py","findings":["x: [1] {2}"]}'
compact_is "escaped quotes and backslashes kept verbatim" '{ "p": "C:\\repo\\a \"q\"" }' '{"p":"C:\\repo\\a \"q\""}'
compact_is "empty object" '{}' '{}'
compact_is "integer literals verbatim" '{ "n": -15, "m": 0, "k": 123456789012345 }' '{"n":-15,"m":0,"k":123456789012345}'
compact_refuses "fraction (jq releases render it differently)" '{"k":1.0}'
compact_refuses "exponent" '{"n":-1.5e3}'
compact_refuses "16-digit integer" '{"big":1234567890123456}'
compact_refuses "unicode escape" '{"a":"\u00e9"}'
compact_refuses "escaped slash" '{"a":"x\/y"}'
compact_refuses "raw control byte in a string" "$(printf '{"a":"x\001y"}')"
compact_refuses "array root" '[1,2]'
compact_refuses "trailing comma" '{"a":1,}'
compact_refuses "bad literal" '{"a":tru}' # spellchecker:disable-line
compact_refuses "split literal" '{"a":tr ue}' # spellchecker:disable-line
compact_refuses "unterminated string" '{"a":"x}'
compact_refuses "invalid escape" '{"a":"x\qy"}'
compact_refuses "trailing backslash escape" '{"a":"x\\\"}'
compact_refuses "bad \\u hex digits" '{"a":"\uZZZZ"}'

# --- Test 3b: builtin envelope is jq's compact rendering, byte for byte -------
# The sink receives exactly one line, and re-rendering it with jq -c yields the
# same bytes: field order, spacing and escapes are jq's. Also: the fallback
# fires (and delivers, through jq) for a data object the compactor refuses.
SINK3B="$(mktemp)"
STUB3B="$(make_sink "$SINK3B")"
hook_event3b=$'Post\tTool\001Use'
# shellcheck disable=SC2030,SC2031 # subshell-local by design
(
export HOOK_TELEMETRY_SINK="$STUB3B"
hook::emit_telemetry "sam\"ple" "$hook_event3b" 'ok é' "$EPOCHREALTIME" "$pretty14c" 2>/dev/null
wait
)
wait_for_sink "$SINK3B" || true
if [[ -s "$SINK3B" ]]; then
lines3b=$(wc -l <"$SINK3B" | tr -d ' ')
rerender=$(jq -c . "$SINK3B" 2>/dev/null | tr -d '\r')
raw=$(tr -d '\r' <"$SINK3B")
if [[ "$lines3b" == 1 && "$raw" == "$rerender" ]]; then
ok "envelope: one compact line, byte-identical to jq -c of itself"
else
fail "envelope: lines=$lines3b raw=[$raw] jq -c=[$rerender]"
fi
if [[ "$raw" == '{"schema_version":"1.0","timestamp":"'*'","hook":"sam\"ple","hook_event":"Post\tTool\u0001Use","status":"ok é","duration_ms":'*',"data":{"tool":"Bash","subject":"git commit -m \"x\"","form":"","n":[1,2,{"a":null,"b":true}],"e":{},"f":[]}}' ]]; then
ok "envelope: field order, escapes and compacted data are jq's"
else
fail "envelope: unexpected bytes: $raw"
fi
else
fail "envelope (3b): sink empty"
fail "envelope (3b): bytes not verifiable"
fi
: >"$SINK3B"
# shellcheck disable=SC2030,SC2031,SC2329 # subshell-local by design; jq is invoked through the lib
(
# The lib discards jq's stderr, so the shadow records its invocation in a
# marker file instead.
jq() {
echo "fallback invoked jq" >"$SINK3B.err"
return 127
}
export HOOK_TELEMETRY_SINK="$STUB3B"
hook::emit_telemetry "sample-hook" "PostToolUse" "ok" "$EPOCHREALTIME" '{"a":"\u00e9"}' 2>/dev/null
wait
)
if grep -q 'fallback invoked jq' "$SINK3B.err" 2>/dev/null && [[ ! -s "$SINK3B" ]]; then
ok "envelope: unprovable data falls back to jq (and fails open when jq fails)"
else
fail "envelope fallback: marker=$(cat "$SINK3B.err" 2>/dev/null) sink=$(cat "$SINK3B")"
fi
: >"$SINK3B"
# shellcheck disable=SC2030,SC2031 # subshell-local by design
(
export HOOK_TELEMETRY_SINK="$STUB3B"
hook::emit_telemetry "sample-hook" "PostToolUse" "ok" "$EPOCHREALTIME" '{"a":"\u00e9"}' 2>/dev/null
wait
)
wait_for_sink "$SINK3B" || true
if [[ "$(jq -r '.data.a' "$SINK3B" 2>/dev/null)" == "é" ]]; then
ok "envelope: the jq fallback delivers the decoded data"
else
fail "envelope fallback with jq: $(cat "$SINK3B")"
fi
rm -f "$SINK3B" "$SINK3B.err" "$STUB3B"

# --- Test 12g: read_file_path fast path answers exactly what jq answers -------
# hook::_fast_file_path_to is compared with the jq filter it replaces on a
# corpus of payload shapes: 0 must carry jq's value, 1 must mean jq printed
# nothing, and 2 (fall back) is always allowed. Includes the shapes that must
# NOT be taken from the wrong place: a top-level file_path, a tool_input
# nested in another object, two keys, a non-string value.
fast_is_jq() { # <desc> <payload>
local got="" rc=0 want
hook::_fast_file_path_to got "$2" || rc=$?
want=$(printf '%s' "$2" | jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null)
case "$rc" in
0) if [[ "$got" == "$want" ]]; then ok "fast file_path: $1 (proven: $(printf '%q' "$got"))"; else fail "fast file_path ($1): got [$got] jq [$want]"; fi ;;
1) if [[ -z "$want" ]]; then ok "fast file_path: $1 (proven absent)"; else fail "fast file_path ($1): said absent, jq says [$want]"; fi ;;
2) ok "fast file_path: $1 (falls back to jq)" ;;
*) fail "fast file_path ($1): rc=$rc" ;;
esac
}
fast_is_jq "Write payload" '{"session_id":"s","tool_name":"Write","tool_input":{"file_path":"src/app.py","content":"x = {\"a\": [1,2]}\n"},"tool_response":{"filePath":"src/app.py","success":true}}'
fast_is_jq "Edit payload with escapes" '{"tool_input":{"file_path":"C:\\repo\\a \"b\".md","old_string":"a\nb","new_string":"","replace_all":false}}'
fast_is_jq "pretty-printed payload" "$(jq -n '{tool_input:{file_path:"docs/x.md"}}')"
fast_is_jq "ASCII unicode escape in the value" '{"tool_input":{"file_path":"\u0041.md"}}'
fast_is_jq "trailing CR and newline stripped" '{"tool_input":{"file_path":"x.md\r\n"}}'
fast_is_jq "keys spelled with unicode escapes" '{"tool\u005finput":{"file\u005fpath":"esc.md"}}'
fast_is_jq "non-ASCII value" '{"tool_input":{"file_path":"é/日本.md"}}'
fast_is_jq "empty value" '{"tool_input":{"file_path":""}}'
fast_is_jq "top-level file_path only" '{"file_path":"top.md","tool_input":{"content":"y"}}'
fast_is_jq "tool_input nested in another object" '{"outer":{"tool_input":{"file_path":"deep.md"}}}'
fast_is_jq "file_path as a string value, not a key" '{"tool_input":{"content":"x"},"y":"file_path"}'
fast_is_jq "no file_path (Bash payload)" '{"tool_name":"Bash","tool_input":{"command":"ls"}}'
fast_is_jq "two file_path keys" '{"file_path":"top.md","tool_input":{"file_path":"in.md"}}'
fast_is_jq "nested object inside tool_input" '{"tool_input":{"file_path":"in.md","meta":{"a":1}}}'
fast_is_jq "non-string value" '{"tool_input":{"file_path":123}}'
fast_is_jq "non-ASCII unicode escape" '{"tool_input":{"file_path":"\u00e9.md"}}'
fast_is_jq "truncated payload" '{"tool_input":{"file_path":"in.md"},"tool_response":{"filePath'
fast_is_jq "array root" '[{"tool_input":{"file_path":"arr.md"}}]'
fast_is_jq "invalid escape in an unrelated string" '{"tool_input":{"file_path":"in.md"},"z":"a\qb"}'
fast_is_jq "bad \\u hex in an unrelated string" '{"tool_input":{"file_path":"in.md"},"z":"\uZZZZ"}'
# The two cases above must not be PROVEN present: jq rejects the whole text.
rc12g=0
hook::_fast_file_path_to got12g '{"tool_input":{"file_path":"in.md"},"z":"a\qb"}' || rc12g=$?
if [[ "$rc12g" -eq 2 ]]; then
ok "fast file_path: an invalid escape anywhere in the payload falls back to jq"
else
fail "fast file_path: invalid escape elsewhere rc=$rc12g got=[${got12g:-}] (want 2)"
fi
# The top-level key must never be taken: the case above proves parity with jq
# (absent); this pins the verdict itself.
rc12g=0
hook::_fast_file_path_to got12g '{"file_path":"top.md","tool_input":{"content":"y"}}' || rc12g=$?
if [[ "$rc12g" -eq 1 ]]; then
ok "fast file_path: top-level file_path is proven absent, never taken"
else
fail "fast file_path: top-level file_path rc=$rc12g got=[${got12g:-}]"
fi
# The size threshold: a payload over 64 KiB is handed to jq unconditionally.
# Built in the shell, not through jq's argv: a 70 KB argument to a native
# Windows jq is truncated by the process command-line cap (see Test 19b), and
# a truncated payload would fall back for the wrong reason. The size is
# asserted before the verdict so a short payload cannot pass vacuously.
printf -v pad12g 'y%.0s' $(seq 1 70000)
big12g="{\"tool_input\":{\"file_path\":\"big.md\",\"content\":\"$pad12g\"}}"
rc12g=0
hook::_fast_file_path_to got12g "$big12g" || rc12g=$?
if ((${#big12g} > 65536)) && [[ "$rc12g" -eq 2 ]]; then
ok "fast file_path: ${#big12g}-byte payload falls back to jq"
else
fail "fast file_path: ${#big12g}-byte payload rc=$rc12g (want 2 on a payload over 65536 bytes)"
fi
# Just under the cap, the same shape is proven by the fast path.
printf -v pad12g 'y%.0s' $(seq 1 60000)
big12g="{\"tool_input\":{\"file_path\":\"big.md\",\"content\":\"$pad12g\"}}"
rc12g=0
hook::_fast_file_path_to got12g "$big12g" || rc12g=$?
if ((${#big12g} <= 65536)) && [[ "$rc12g" -eq 0 && "$got12g" == "big.md" ]]; then
ok "fast file_path: ${#big12g}-byte payload under the cap is proven by the fast path"
else
fail "fast file_path: ${#big12g}-byte payload rc=$rc12g got=[${got12g:-}] (want 0/big.md)"
fi
unset got12g rc12g big12g pad12g

# --- Test 12h: hook::dirname_to, the builtin dirname of a resolver answer -----
dirname_is() { # <path> <want>
local got=""
hook::dirname_to got "$1"
if [[ "$got" == "$2" ]]; then
ok "dirname_to: $1 -> $2"
else
fail "dirname_to: $1 -> [$got] want [$2]"
fi
}
dirname_is /a/b/c.md /a/b
dirname_is /c.md /
dirname_is c.md .
dirname_is C:/repo/x.md C:/repo
dirname_is /c/x.md /c

# --- Test 15: hook::emit_skip_notice — valid JSON, both channels, jq-free -----
notice=$(hook::emit_skip_notice PostToolUse 'my-plugin: tool "x" missing — skipped')
if jq -e '.hookSpecificOutput.hookEventName == "PostToolUse"
Expand Down
2 changes: 1 addition & 1 deletion plugins/actionlint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "actionlint",
"version": "0.8.29",
"version": "0.8.30",
"description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.",
"author": {
"name": "Melodic Software",
Expand Down
16 changes: 16 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
All notable changes to the `actionlint` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.8.30]

### Changed

- **Vendored `hook-utils.sh` builds the telemetry envelope and reads `file_path`
with shell builtins.** `hook::emit_telemetry` no longer spawns two jq
processes, a mktemp and an rm per run: the envelope is assembled in the shell
as one compact line (the same document jq produced, now `jq -c` shaped), with
jq kept only as the fallback for a data object the builtin compactor cannot
prove. `hook::read_file_path` takes `.tool_input.file_path` without jq on the
well-formed payload shape and resolves the file, project root and temp roots
with one batched `realpath` instead of one process each. Same verdicts, same
emitted path, same sink record; phase 4b of the hook-performance program
(#3623). The copy is bumped because `scripts/sync-hook-utils.sh` keeps every
carrying plugin byte-identical.

## [0.8.29]

### Changed
Expand Down
Loading