-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathhook-utils.sh
More file actions
2139 lines (2076 loc) · 94.6 KB
/
Copy pathhook-utils.sh
File metadata and controls
2139 lines (2076 loc) · 94.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# 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-hook-utils.sh; CI rejects drifted copies.
# Guard against double-sourcing.
[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0
readonly _HOOK_UTILS_LOADED=1
# Per-hook kill switch via the plugin's <name>_enabled userConfig boolean,
# read from the hook-process CLAUDE_PLUGIN_OPTION_<NAME>_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` — start with every customization
# (CLAUDE.md, plugins, skills, hooks, MCP servers) disabled
# * `disableAllHooks` — disable all hooks and any custom status line
# * `claude plugin disable|enable <name>` — per-plugin, dependency-aware
# This helper stays scoped to the one thing it owns: the plugin's own
# `<name>_enabled` userConfig boolean, surfaced to hook processes as the native
# `$CLAUDE_PLUGIN_OPTION_<KEY>` mirror.
# hook::is_enabled <NAME> — 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.
#
# The statusline tee is exactly that caller: it is a TRANSPARENT WRAPPER around
# the user's real statusline, so exiting 0 on "disabled" would suppress the
# wrapped command's output and blank the status line. It needs to skip its own
# side effect and still pass 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() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
# tr drops the residual C0 bytes; if tr itself is unavailable, fall back to
# the escaped string as-is — notice text is hook-authored and does not carry
# raw control bytes in practice.
local out
out=$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037' 2>/dev/null) || out="$s"
printf '%s' "$out"
}
# Emit hook JSON carrying an agent-channel context (additionalContext) and/or a
# user-channel message (systemMessage) as ONE document — CC parses the hook's
# whole stdout as a single JSON doc, so a run that has both lint findings and a
# pending skip notice must compose them here rather than print twice. Either
# channel may be empty; emits nothing when both are.
# hook::emit_channels PostToolUse "$ctx" "$sysmsg"
hook::emit_channels() {
local event="$1" ctx="$2" sysmsg="$3"
[[ -n "$ctx" || -n "$sysmsg" ]] || return 0
local out="{"
if [[ -n "$ctx" ]]; then
out+='"hookSpecificOutput":{"hookEventName":"'"$(hook::json_escape "$event")"'","additionalContext":"'"$(hook::json_escape "$ctx")"'"}'
[[ -n "$sysmsg" ]] && out+=","
fi
[[ -n "$sysmsg" ]] && out+='"systemMessage":"'"$(hook::json_escape "$sysmsg")"'"'
out+="}"
printf '%s\n' "$out"
}
# Visible skip notice: the same message on both channels. The caller must exit 0
# right after unless it composes via hook::emit_channels itself.
# hook::emit_skip_notice PostToolUse "my-plugin: tool X not found — ..."
#
# When hook::notice_once just authorized a RENEW (periodic re-notice), the
# message is forced to one short line: no PATH dump, capped length. That is
# what makes a re-notice every N edits affordable (#3128). The first notice
# still carries the full text, with PATH probed: trimmed by
# hook::format_path_probed so other plugins' bin dirs are not dumped.
hook::emit_skip_notice() {
local event="$1" msg="$2"
if [[ "$msg" == *$'\nPATH probed: '* ]]; then
local prefix="${msg%%$'\nPATH probed: '*}"
local probed="${msg#*$'\nPATH probed: '}"
msg="${prefix}"$'\n'"PATH probed: $(hook::format_path_probed "$probed")"
fi
if [[ "${HOOK_NOTICE_KIND:-full}" == "renew" ]]; then
msg="${msg%%$'\n'*}"
if ((${#msg} > 240)); then
msg="${msg:0:237}..."
fi
if [[ -n "${HOOK_NOTICE_COUNT:-}" ]]; then
msg="${msg} [${HOOK_NOTICE_COUNT} skips this agent/session]"
fi
fi
hook::emit_channels "$event" "$msg" "$msg"
}
# Trim a PATH dump to directories that can plausibly hold a host / user /
# repo-local tool. Other plugins' bin/hooks dirs are the 60+ entry dump that
# made the first skip notice 10 KB (#3128 / #3134). Cap kept entries; say
# how many were omitted.
# hook::format_path_probed # reads $PATH
# hook::format_path_probed "$raw_path" # trim a dumped PATH string
hook::format_path_probed() {
local raw="${1:-${PATH:-}}"
[[ -n "$raw" ]] || {
printf '%s' '<unset>'
return 0
}
[[ "$raw" == '<unset>' ]] && {
printf '%s' '<unset>'
return 0
}
local rest="$raw" p
local -a kept=()
local omitted=0
local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
local max=12
while [[ -n "$rest" ]]; do
if [[ "$rest" == *:* ]]; then
p="${rest%%:*}"
rest="${rest#*:}"
else
p="$rest"
rest=""
fi
[[ -n "$p" ]] || continue
if [[ "$p" == *'/plugins/'* ]] && [[ "$p" == *'/bin'* || "$p" == *'/hooks'* ]]; then
if [[ -z "$plugin_root" || "$p" != "$plugin_root"* ]]; then
omitted=$((omitted + 1))
continue
fi
fi
if ((${#kept[@]} < max)); then
kept+=("$p")
else
omitted=$((omitted + 1))
fi
done
local out="" i
for i in "${!kept[@]}"; do
[[ $i -gt 0 ]] && out+=':'
out+="${kept[$i]}"
done
if ((omitted > 0)); then
out+=" …(+${omitted} omitted)"
fi
printf '%s' "$out"
}
# systemMessage-only variant for hook events with no additionalContext channel
# (e.g. Notification).
hook::emit_system_message() {
hook::emit_channels "" "" "$1"
}
# Skip-notice latch (#3128). Returns 0 (emit now) on the first fire for a
# given <key> in the current (session, agent) pair, then every
# HOOK_NOTICE_RENEW_EVERY skips thereafter (default 8); returns 1 otherwise.
# A missing-tool notice behind a broad matcher must not repeat the full
# diagnostic on every edit, but one notice for the whole session was the
# opposite defect: later edits (and every subagent sharing the session id)
# went silently unchecked, with no retained skip count unless
# HOOK_TELEMETRY_SINK was wired.
#
# The marker keys on session AND agent (agent_id, else the transcript_path
# basename, else no-agent) so a subagent gets its own first notice. The
# marker file stores the skip count, independent of the telemetry sink.
# hook::emit_skip_notice reads HOOK_NOTICE_KIND=renew and emits one short
# line, no PATH dump. SessionEnd summary is not wired: the count lives in
# the marker and the renew notice prints it.
#
# Fails open toward visibility: when no marker can be tracked, emit every
# time (KIND=full).
# hook::notice_once "my-plugin-jq" "$INPUT" && hook::emit_skip_notice ...
HOOK_NOTICE_KIND=full
HOOK_NOTICE_COUNT=0
HOOK_NOTICE_RENEW_EVERY="${HOOK_NOTICE_RENEW_EVERY:-8}"
hook::notice_once() {
local key="$1" input="${2:-}" session="no-session" agent="no-agent"
HOOK_NOTICE_KIND=full
HOOK_NOTICE_COUNT=0
if [[ "$input" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
session="${BASH_REMATCH[1]}"
session="${session//[^A-Za-z0-9_-]/-}"
fi
if [[ "$input" =~ \"agent_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
agent="${BASH_REMATCH[1]}"
elif [[ "$input" =~ \"transcript_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]]; then
agent="${BASH_REMATCH[1]}"
agent="${agent##*/}"
agent="${agent%.jsonl}"
fi
agent="${agent//[^A-Za-z0-9_-]/-}"
[[ -n "$agent" ]] || agent="no-agent"
local dir="${CLAUDE_PLUGIN_DATA:-}"
[[ -n "$dir" ]] || return 0
dir="$dir/skip-notices"
mkdir -p "$dir" 2>/dev/null || return 0
find "$dir" -type f -mtime +7 -delete 2>/dev/null
local marker="$dir/${key}.${session}.${agent}"
local count=0
if [[ -f "$marker" ]]; then
count="$(tr -d '[:space:]' <"$marker" 2>/dev/null || true)"
[[ "$count" =~ ^[0-9]+$ ]] || count=1
fi
count=$((count + 1))
printf '%s\n' "$count" >"$marker" 2>/dev/null || return 0
HOOK_NOTICE_COUNT="$count"
local every="${HOOK_NOTICE_RENEW_EVERY:-8}"
[[ "$every" =~ ^[1-9][0-9]*$ ]] || every=8
if [[ "$count" -eq 1 ]]; then
HOOK_NOTICE_KIND=full
return 0
fi
if ((count % every == 0)); then
HOOK_NOTICE_KIND=renew
return 0
fi
HOOK_NOTICE_KIND=silent
return 1
}
# 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.
# RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0
hook::raw_file_path() {
[[ "$1" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]] || return 1
[[ -n "${BASH_REMATCH[1]}" ]] || return 1
printf '%s' "${BASH_REMATCH[1]}"
}
# ============================================================================
# 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 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 judgement about severity. A hook belongs in
# the fail-closed class iff it ALREADY fails closed on some other
# "I cannot parse this input" condition. Today exactly two do, both via a
# MAX_COMMAND_LEN ceiling above which an unparsable command is denied unread:
#
# plugins/guardrails/hooks/block-dangerous-git.sh
# plugins/guardrails/hooks/block-no-verify.sh
#
# Those two scripts 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.
# plugins/guardrails/hooks/require-jq-posture.test.sh pins the membership so the
# two cannot drift apart again.
#
# 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 behaviour) 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 jq gate — the default. For hooks whose input parsing cannot proceed
# without jq and whose finding is advisory. When jq is absent: one visible skip
# notice per session, 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"
hook::require_jq() {
command -v jq >/dev/null 2>&1 && return 0
local event="$1" plugin="$2" input="${3:-}"
if hook::notice_once "${plugin}-jq" "$input"; then
hook::emit_skip_notice "$event" \
"$plugin: jq not found on PATH — hook skipped for this session. Install jq (https://jqlang.org/download/) to enable it."
fi
exit 0
}
# Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible
# operation, per the membership criterion in the posture block above. When jq is
# absent the tool call is DENIED (exit 2) with jq named as the missing
# prerequisite and the same install route the fail-open notice uses.
#
# No notice_once here, and that is deliberate: this message is not a
# once-per-session heads-up about a degraded hook, it is THIS tool call's denial
# reason. Suppressing the repeat would leave a later denial unexplained. It also
# goes to stderr rather than through hook::emit_channels, because stderr is the
# channel a PreToolUse exit 2 feeds back to the agent.
#
# The kill switch stays the only supported deliberate bypass: a consumer who
# genuinely wants the operation unguarded on a jq-less machine sets the guard's
# own *_enabled userConfig option to false, which hook::check_enabled honours
# BEFORE this gate is ever reached.
# DISCLOSED COST, because it is not small: this guard runs on EVERY Bash and
# PowerShell tool call, and without jq it cannot read the command at all — so it
# cannot tell a dangerous one from a safe one and denies both. On a machine
# without jq every such tool call is blocked until jq is installed or the kill
# switch is set. That is the hard dependency #2146 accepted when it chose this
# posture over a jq-free substring pre-check, which was rejected for
# manufacturing a false sense of coverage.
#
# $1 = the hook's own id (for the message), $2 = the user-facing *_enabled
# userConfig option name that turns this guard off.
# hook::require_jq_blocking guardrails-block-dangerous-git block_dangerous_git_enabled
hook::require_jq_blocking() {
command -v jq >/dev/null 2>&1 && return 0
local hook_id="$1" option="${2:-}"
echo "BLOCKED: $hook_id cannot read the tool payload — the required prerequisite \`jq\` is not on PATH." >&2
echo "This guard blocks irreversible operations, so a missing prerequisite denies the call rather than silently skipping the guard (#2146)." >&2
if [[ -n "$option" ]]; then
echo "Install jq (https://jqlang.org/download/), or set the \`$option\` plugin option to false (/plugin configure) to bypass this guard." >&2
else
echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2
fi
exit 2
}
# Normalize a path for the membership comparison below: backslashes → forward
# slashes, and — only on Windows/MSYS, whose filesystem is case-insensitive —
# fold a leading drive (POSIX `/c/...` or `c:/...`) to an upper-case drive
# letter + lower-cased remainder so the byte-exact comparison is effectively
# case-insensitive. The fold is gated on the host (OSTYPE), NOT on the path
# shape: on a case-sensitive POSIX filesystem a real single-letter top-level
# directory such as `/c/Repo` must pass through unchanged, otherwise it would
# collapse with `/c/repo` and the membership guard would admit a sibling
# outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the
# emitted path is always the caller's original.
hook::normalize_path() {
local p="${1//\\//}"
case "${OSTYPE:-}" in
msys* | cygwin* | win32)
if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then
local rest="${p:2}"
printf '%s' "${BASH_REMATCH[1]^}:${rest,,}"
return
fi
;;
*) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below
esac
printf '%s' "$p"
}
# Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on
# Windows/MSYS hosts, where GNU realpath resolves symlinks but leaves short
# names as-is. Without this a short-form file_path — the shape Claude Code's
# own scratchpad paths take — fails the membership prefix comparison below and
# an IN-project file is silently skipped. 8.3 generation is a PER-VOLUME
# property (`fsutil 8dot3name query <vol>`): a checkout on a volume that
# generates short names hits this constantly while one on a non-generating
# volume can never reproduce it, so the guard must not assume either.
#
# `cygpath -m` (form conversion only) is compared against `cygpath -l -m`
# (long names via Win32), and the path is replaced only when the two DIFFER —
# a legitimate long name that merely contains '~' (foo~bar.md) converts
# identically both ways and passes through byte-for-byte untouched. A genuine
# expansion returns mixed form (C:/...); the membership comparison normalizes
# both sides, so the form change is absorbed, and any such path failed the
# comparison outright before this expansion existed. Fail-open on this host
# class: cygpath ships with Git Bash (the documented Windows bash), so its
# absence or failure keeps the resolver's answer unchanged — degrading to the
# pre-expansion comparison, same doctrine as the resolver fallback below.
hook::expand_8dot3() {
local p="$1"
case "${OSTYPE:-}" in
msys* | cygwin* | win32) ;;
*)
printf '%s' "$p"
return
;;
esac
if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then
local plain long
if plain=$(cygpath -m -- "$p" 2>/dev/null) &&
long=$(cygpath -l -m -- "$p" 2>/dev/null) &&
[[ -n "$long" && "$long" != "$plain" ]]; then
printf '%s' "$long"
return
fi
fi
printf '%s' "$p"
}
# Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names
# expanded — for the membership comparison below, so an in-project symlink
# pointing outside the project root cannot defeat the guard (the lexical path
# would pass the prefix check while the write lands elsewhere) and a short-form
# spelling of an in-project path cannot dodge it (the long-form prefix would
# never match). GNU realpath ships with Git Bash and Linux coreutils;
# readlink -f covers the BSD/macOS hosts that have no realpath. When neither
# resolver exists the caller still receives the lexical path unchanged — the
# guard is defense-in-depth scoping for a file the agent already wrote via its
# own tools, so degrading to the historical comparison beats silently disabling
# the hook on those hosts — but the answer is now DISTINGUISHABLE: return 1 and
# HOOK_PHYSICAL_PATH_UNRESOLVED=1. Success (return 0) means resolved; advisory
# callers that ignore the status keep today's behavior. Guards that must fail
# closed branch on the return code or on HOOK_PHYSICAL_PATH_UNRESOLVED. The 8.3
# expansion applies only on the resolver's success path: consumers that fail
# closed on an unresolved signature must not see a form-converted path instead.
# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED
hook::physical_path() {
local resolved
HOOK_PHYSICAL_PATH_UNRESOLVED=0
if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then
if [[ -n "$resolved" ]]; then
hook::expand_8dot3 "$resolved"
return 0
fi
fi
HOOK_PHYSICAL_PATH_UNRESOLVED=1
printf '%s' "$1"
return 1
}
# True when <normalized-path> sits inside one of this host's temp trees.
# Both arguments and candidates go through the same canonicalize+normalize
# pipeline as the membership comparison, because the same directory has several
# spellings: on Git Bash `TMPDIR=/tmp` while `TEMP`/`TMP` carry the Windows form
# of the identical directory, and `realpath` resolves the Windows form to a
# drive path while leaving `/tmp` as `/tmp`. Neither form alone matches a
# `file_path` that could arrive in either, so every candidate is compared and a
# match on any one is a match. Duplicates are resolved once.
#
# Candidates are the environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX
# defaults, never a hardcoded platform assumption.
# hook::under_temp_root "$norm_path" && ...
hook::under_temp_root() {
local target="$1" cand norm seen=""
for cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do
[[ -n "$cand" && -d "$cand" ]] || continue
case "$seen" in
*"|$cand|"*) continue ;;
*) ;; # first sighting of this candidate — resolve it below
esac
seen="$seen|$cand|"
norm=$(hook::normalize_path "$(hook::physical_path "$cand")")
# The filesystem root as a temp candidate contains every absolute path;
# trimming its only slash would empty the candidate and discard it.
[[ "$norm" == / ]] && return 0
norm="${norm%/}"
[[ -n "$norm" ]] || continue
# Equality counts: a project root that IS the temp root must answer true,
# otherwise the exemption below would not recognize it as a temp-rooted
# project and would reject every file in it.
[[ "$target" == "$norm" || "$target" == "$norm"/* ]] && return 0
done
return 1
}
# True when <dir> sits inside a git working tree. Unsets locating globals so an
# inherited GIT_DIR cannot make an out-of-tree directory look in-tree — the same
# discipline markdown-format adopted for #972.
# hook::in_git_working_tree "$(dirname "$file")" && ...
hook::in_git_working_tree() {
(
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_CEILING_DIRECTORIES \
GIT_DISCOVERY_ACROSS_FILESYSTEM
git -C "$1" rev-parse --show-toplevel
) >/dev/null 2>&1
}
# Parse file_path from PostToolUse JSON on stdin; validate existence and (when
# CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership
# comparison are canonicalized (symlinks resolved) first, so neither an
# escaping symlink nor a project root reached via a symlinked path (e.g.
# macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip.
# FILE=$(hook::read_file_path) || exit 0
hook::read_file_path() {
local file
file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null)
[[ -n "$file" ]] || return 1
[[ -f "$file" ]] || return 1
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
local norm_file norm_project
norm_file=$(hook::normalize_path "$(hook::physical_path "$file")")
norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")")
norm_project="${norm_project%/}"
# Anchor on a path-segment boundary: accept the project root itself or a
# child under it, but not a sibling whose name merely shares the prefix
# (e.g. /c/repo must not admit /c/repo-backup/...).
if [[ "$norm_file" != "$norm_project" && "$norm_file" != "$norm_project"/* ]]; then
return 1
fi
# Prefix membership alone is not project membership. When the project dir is
# the user's home (or any ancestor of the temp tree), every scratch file the
# harness writes — its own per-session scratchpad lives under the OS temp
# root — passes the prefix test and reaches hooks that then lint, rewrite,
# or autocorrect a file that is not project content and has no project
# config to opt out with. Same shape as hook-precision rule 5: gate on what
# the path IS, not on a literal substring of an over-broad project dir.
#
# The exemption is deliberate and load-bearing: when the project root ITSELF
# lives under temp, a file under temp is the project (a fixture checkout
# built by `mktemp -d`, which is how this repo's own hook suites run), so
# the branch must not fire. Only a temp-tree file reached from a project
# root OUTSIDE the temp tree is scratch.
if hook::under_temp_root "$norm_file" && ! hook::under_temp_root "$norm_project"; then
return 1
fi
elif command -v git >/dev/null 2>&1; then
# When CLAUDE_PROJECT_DIR is unset, scope to git-working-tree membership so
# scratch files outside any repository are not mutated by formatter hooks
# (#1091 / #972).
local file_physical
file_physical=$(hook::physical_path "$file")
if [[ -L "$file" && "$file_physical" == "$file" ]]; then
return 1
fi
if ! hook::in_git_working_tree "$(dirname "$file_physical")"; then
return 1
fi
else
# No project dir and no git: fail closed rather than revert to unscoped
# pre-#1091 behavior (#1091).
return 1
fi
printf '%s' "$file"
}
# Resolve the repository root (working-tree top) for a path inside the tree.
# markdownlint config auto-discovery is CWD-anchored, so the hook cd's here
# before linting. File-anchored (`git -C "$hint" rev-parse --show-toplevel`)
# so it is correct for clones, linked worktrees, and bare-hub clones; when git
# cannot resolve, still returns the hint (with a trailing /.claude stripped) so
# advisory callers keep today's fallback — but the answer is now
# DISTINGUISHABLE: return 1 and HOOK_REPO_ROOT_UNRESOLVED=1. Success (return 0)
# means git answered; advisory callers that ignore the status are unchanged.
# Guards that must fail closed branch on the return code or on
# HOOK_REPO_ROOT_UNRESOLVED.
# ROOT=$(hook::repo_root "$some_path")
# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_REPO_ROOT_UNRESOLVED
hook::repo_root() {
local hint="${1:-.}"
local root
HOOK_REPO_ROOT_UNRESOLVED=0
root=$(git -C "$hint" rev-parse --show-toplevel 2>/dev/null | tr -d '\r')
if [[ -n "$root" ]]; then
printf '%s' "$root"
return 0
fi
root="$hint"
root="${root%/.claude}"
root="${root%\\.claude}"
HOOK_REPO_ROOT_UNRESOLVED=1
printf '%s' "$root"
return 1
}
# Repo-relative form of <file> under <repo-root> — the shape the telemetry
# schema requires of `data.file` ("relative to the consuming repo root").
#
# Both sides go through `cygpath -lm` (long name, forward-slash mixed form)
# when it is available, so the prefix strip compares ONE representation: on
# Windows Git Bash `git rev-parse --show-toplevel` answers with a drive-letter
# path while file_path may arrive in POSIX mount form, and the raw strip never
# matches. On Linux/macOS cygpath is absent and both paths are already POSIX,
# so the strip runs directly.
#
# What survives the strip is not trusted to BE relative. A mount/symlink
# mismatch, or a cygpath that answers for one side and not the other, leaves
# the whole absolute path, which embeds the developer's username and breaks the
# schema contract. Such a path degrades to its basename rather than leaking
# (#1133), and the answer is DISTINGUISHABLE: return 1 and
# HOOK_REPO_RELATIVE_DEGRADED=1. Success (return 0) means the path is genuinely
# repo-relative. Telemetry callers that ignore the status still get the safe
# value; a caller that feeds the result to a TOOL must branch on it, because a
# bare basename resolved against the repo root names a different file.
# FILE_REL=$(hook::repo_relative_path "$FILE" "$REPO_ROOT")
#
# Callers under `set -e` must not take the status from a bare assignment: a
# degraded answer returns 1, and `FILE_REL=$(hook::repo_relative_path ...)`
# would abort the shell. Append `|| <flag>=1` (what every tool-feeding caller
# here does) or `|| :` to keep the failure handled.
# shellcheck disable=SC2034 # public contract: callers may read HOOK_REPO_RELATIVE_DEGRADED
hook::repo_relative_path() {
local file="$1" root="$2" rel="$1"
HOOK_REPO_RELATIVE_DEGRADED=0
# An empty root anchors nothing, and the strip must not run against one:
# `${file#""/}` merely shaves the leading slash, handing back a path that is
# still the caller's absolute path but no longer LOOKS absolute to the
# redaction below, so it would leak with a success status. Skipping the strip
# leaves rel as the input, which the redaction then degrades correctly.
if [[ -n "$root" ]]; then
if command -v cygpath >/dev/null 2>&1; then
local file_lm root_lm
file_lm=$(cygpath -lm "$file" 2>/dev/null)
root_lm=$(cygpath -lm "$root" 2>/dev/null)
if [[ -n "$file_lm" && -n "$root_lm" ]]; then
rel="${file_lm#"$root_lm"/}"
fi
else
rel="${file#"$root"/}"
fi
fi
# POSIX-absolute, drive-letter, and UNC are the three spellings an unstripped
# path arrives in. Trim on either separator: a mixed-form path carries both.
case "$rel" in
/* | [A-Za-z]:* | \\\\*)
rel="${rel##*/}"
rel="${rel##*\\}"
HOOK_REPO_RELATIVE_DEGRADED=1
;;
*) ;; # already repo-relative, nothing to redact
esac
printf '%s' "$rel"
((HOOK_REPO_RELATIVE_DEGRADED == 0))
}
# Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe
# late-EOF stalls via a bounded read on the inherited fd0. Returns the payload
# on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the
# read stalled before a complete JSON payload arrived (caller may block).
#
# The bound (stdin_read_timeout userConfig option, in seconds, read via
# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a
# total one: only a window in which NOTHING arrives ends the read. A single
# `read -d ''` bounded by -t was a total bound, and because bash consumes
# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it
# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch,
# so fail-closed callers blocked a legitimate write and fail-open callers
# skipped silently. Two things together make the bound mean what it says:
#
# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of
# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms
# to ~85 ms.
# * The timer measures inactivity, not the read. `read -t` is a deadline for
# the WHOLE requested read, so a producer that keeps delivering but slower
# than one chunk per window would still trip it. `read` assigns whatever it
# did receive even when it times out, so any byte counts as progress: the
# loop keeps that partial chunk and reads on. Only the absence of bytes for a
# whole stdin_read_timeout is a stall.
# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only
# that its window expired, never WHEN inside it the last byte arrived, so a
# bound armed as one window would declare a stall anywhere between one and
# TWO bounds after the pipe actually went quiet. Slicing bounds that
# overshoot: with four slices a stall lands within a quarter-bound of the
# configured interval. That residual quarter is the honest limit of the
# approximation, and it errs toward waiting — never toward declaring a live
# producer dead. On a shell whose `read -t` rejects the fractional slice the
# count degrades to 1, i.e. the unsliced one-to-two-bound behavior.
#
# Reading on is skipped once the buffer already parses as whole JSON, so the
# late-EOF case costs ONE slice past the payload rather than the rest of the
# bound — a producer holding the pipe open cannot be distinguished from a slow
# one until a window expires, so some wait there is the floor.
#
# The trade this makes: a producer trickling bytes indefinitely is never cut off
# here. That is deliberate — the harness already caps a `command` hook at 600 s
# by default (https://code.claude.com/docs/en/hooks), and blocking a live
# producer is exactly the failure this function had.
#
# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t
# returns >128 — so the loop takes the verdict off $? rather than inferring it
# from elapsed-time arithmetic. jq (when present) is still the completeness
# backstop: a stall that nevertheless delivered a complete payload is the Win32
# late-EOF case this function exists for and must succeed, not block. A
# missing/broken jq (exit 127) fails open like absent jq.
#
# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+
# support, so the pre-4.1 branch falls back to the delimiter read, which already
# reads to EOF and is fast enough on native POSIX pipes. Same guard and same
# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming
# loop wraps both forms, so 3.2 gets the progress semantics too — just in
# byte-at-a-time-sized steps.
# INPUT=$(hook::buffer_stdin) || exit 0
# The `read -N` availability guard, split out as its own predicate so the
# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is
# readonly, so it cannot be shadowed, but a test can override this function
# after sourcing. Not a consumer seam — nothing reads it from the environment.
hook::read_supports_nchars() {
((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1)))
}
# Is the buffered text already a complete JSON document? Lets the read stop the
# moment the payload is whole instead of spending another idle window waiting
# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is
# unavailable or broken (exit 127) as well as when the text is incomplete — the
# caller must keep reading rather than guess, and the caller's own fail-open
# handling for absent jq is unaffected.
hook::json_complete() {
# Structural pre-filter before paying for a jq process: a hook payload is a
# JSON object, so a complete one ends in `}` (possibly with trailing newline
# or CR). Testing the last few characters is O(1) and skips the spawn for
# every mid-payload buffer, which is what keeps this off the hot path of a
# large or slow read. A false negative here costs only the early break — the
# read continues and the caller's final completeness check still decides — so
# the pre-filter can never turn a whole payload into a wrong verdict.
[[ "${1: -4}" == *"}"* ]] || return 1
command -v jq >/dev/null 2>&1 || return 1
# `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe
# that bash fills itself, so a payload at or above the pipe capacity (65536
# bytes on this platform — exactly one read chunk) blocks the shell forever
# before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here
# indefinitely while 65000 returned immediately. A separate writer process
# cannot deadlock that way.
printf '%s' "$1" | jq -e . >/dev/null 2>&1
}
# Resolve the read timeout to a value THIS shell's `read -t` will actually
# accept, falling back to the documented default of 2 otherwise.
#
# The configured value reaches `read -t` directly, and an unusable one is not a
# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1
# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads
# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse
# still: it makes `read` return immediately having consumed nothing, which would
# spin the loop.
#
# Acceptance is settled by PROBING this shell rather than consulting a version
# table: which spellings `read -t` accepts varies across the Bash releases these
# hooks support (fractional values are not universally available, and the
# upstream changelog does not date their introduction), so asking the running
# shell is exact where a version check would be a guess. Reading /dev/null hits
# EOF immediately, so a valid timeout produces no stderr at all. The probe is
# skipped for the default, which is known-good everywhere.
# Minimum 0.00001 s (10 µs): smaller positive values are a silent disable — the
# read returns before payload bytes arrive, same class as exact zero (#1883).
readonly HOOK_STDIN_READ_TIMEOUT_MIN_MICROS=10
hook::resolve_read_timeout() {
local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}"
if [[ "$t" != "2" ]]; then
local probe
# shellcheck disable=SC2034 # `discard` is the read target; only stderr matters
probe=$(read -r -t "$t" discard </dev/null 2>&1)
if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then
t=2
elif [[ "$t" =~ ^([0-9]+)(\.([0-9]+))?$ ]]; then
local whole="${BASH_REMATCH[1]}" frac="${BASH_REMATCH[3]:-}"
frac="${frac}000000"
frac="${frac:0:6}"
local micros=$((10#$whole * 1000000 + 10#$frac))
if ((micros < HOOK_STDIN_READ_TIMEOUT_MIN_MICROS)); then
t=2
fi
fi
fi
printf '%s' "$t"
}
# How many slices the idle bound is divided into. `read -t` reports only that a
# window expired, never WHEN inside it the last byte arrived, so a bound armed as
# one window declares a stall anywhere between one and two bounds after the pipe
# actually went quiet. Asking more often shrinks that: with N slices, a stall is
# declared within one slice of the configured interval. Four is the compromise —
# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the
# idle path to four cheap builtin reads.
HOOK_STDIN_READ_SLICES=4
# Resolve the per-read slice for an already-resolved timeout, printing
# "<slice> <count>". Falls back to "<timeout> 1" — exactly the unsliced
# behavior — when this shell's `read -t` will not accept the fractional slice,
# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch
# already covers. Probed, not version-tested, for the same reason as
# hook::resolve_read_timeout.
hook::resolve_read_slice() {
local t="$1" slice=""
# The division is fixed-point shell arithmetic, not `awk t/n`. A hook pays for
# every external process it spawns — ~140 ms each on Windows Git Bash, where
# process creation is fork() emulation — and this one spawned awk on EVERY
# invocation to divide two numbers. Bash has no float arithmetic, so the value
# is carried in micro-units (1e-6 s; `read -t` never resolves finer than
# milliseconds anyway) and rounded back half-up to the same three decimals
# awk's "%.3f" produced. Identical output for every timeout in practice; the
# one seam is a quotient landing on an exact half-millisecond (t=2.006 → 0.502
# here, 0.501 under awk, whose C printf rounds the binary approximation), a
# 1 ms difference in an idle bound that is itself an approximation — see the
# residual-quarter note above. printf -v, not $( ), because a command
# substitution forks the shell even for a builtin — the fork IS the cost here.
# A value the pattern rejects, or one large enough to overflow the arithmetic
# into a negative, leaves `slice` unusable and falls through to the unsliced
# "<t> 1" form below, exactly as an awk failure did.
if [[ "$t" =~ ^([0-9]+)(\.([0-9]+))?$ ]] && ((HOOK_STDIN_READ_SLICES > 0)); then
local whole="${BASH_REMATCH[1]}" frac="${BASH_REMATCH[3]:-}"
frac="${frac}000000"
frac="${frac:0:6}"
local micros=$((10#$whole * 1000000 + 10#$frac))
local milli=$(((micros / HOOK_STDIN_READ_SLICES + 500) / 1000))
printf -v slice '%d.%03d' "$((milli / 1000))" "$((milli % 1000))"
fi
if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then
local probe
# shellcheck disable=SC2034 # `discard` is the read target; only stderr matters
probe=$(read -r -t "$slice" discard </dev/null 2>&1)
if [[ -z "$probe" ]]; then
printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES"
return 0
fi
fi
printf '%s 1' "$t"
}
hook::buffer_stdin() {
local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0
local read_timeout read_slice slice_count
read_timeout=$(hook::resolve_read_timeout)
read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout")
local -a read_opts=(-r -t "$read_slice")
if hook::read_supports_nchars; then
read_opts+=(-N 65536)
else
read_opts+=(-d '')
fi
while :; do
chunk=""
read_rc=0
# shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array
IFS= read "${read_opts[@]}" chunk || read_rc=$?
input+="$chunk"
# Any byte at all resets the idle count — that, not the read's exit status,
# is what makes this an idle timer rather than a per-read deadline.
[[ -n "$chunk" ]] && idle_slices=0
if ((read_rc == 0)); then
# A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL
# read that consumed nothing, however, cannot make progress, so continuing
# would spin: break instead. hook::resolve_read_timeout already excludes
# the only known way to reach that (`read -t 0`, which returns success
# without consuming); this keeps loop termination a structural property
# rather than a consequence of validation staying correct.
[[ -n "$chunk" ]] || break
continue
fi
if ((read_rc > 128)); then
# A slice expired. Bytes in it mean the producer is alive: keep them and
# read on. Only slice_count CONSECUTIVE empty slices — one whole
# stdin_read_timeout with nothing arriving — is the stall this guard
# exists to catch, which is why the count is not reset here.
if [[ -n "$chunk" ]]; then
# ... but stop immediately if what we already hold is a whole JSON
# document. That is the Win32 late-EOF case — the payload arrived, the
# pipe just never closed — and reading on there would spend the rest of
# the bound waiting for an EOF that is not coming.
if hook::json_complete "${input//$'\r'/}"; then
validated=1
break
fi
continue
fi
# An EMPTY slice can also be the late-EOF case: the payload may have been
# completed by the PREVIOUS read, which returned rc 0 and so never reached
# the completeness check above. That happens whenever the payload ends on a
# 65536-character boundary, and without this the helper would wait out the
# whole bound instead of a single slice. Checking here rather than on the
# rc-0 path keeps jq off the hot path — a large payload costs one check
# when the producer first pauses, not one per 64 KB chunk.
#
# Only on the FIRST empty slice of a quiet period: the buffer cannot grow
# while nothing is arriving, so re-checking an unchanged buffer would spend
# a jq process per slice to re-derive the same answer — enough overhead on
# a slow-spawning host to cost more than slicing saves. idle_slices resets
# the moment a byte lands, so the next quiet period checks again.
if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then
validated=1
break
fi
((idle_slices++))
((idle_slices >= slice_count)) || continue
stalled=1
fi
break # EOF (rc 1), a full idle bound with no bytes, or a read error
done
# CR-stripped in the shell, not through `printf | tr`: that pipeline cost a
# fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte
# class from a string bash can already rewrite in place. Same bytes either way
# — which is what lets the completeness verdict below be reused.
input="${input//$'\r'/}"
[[ -n "$input" ]] || return 1
local jq_rc=0
# The loop above breaks on hook::json_complete only when jq PARSED this exact
# CR-stripped buffer as a whole document, so re-probing it here would spend a
# second jq process to re-derive an answer already in hand. jq's absence or
# failure never sets that flag (json_complete returns non-zero for both), so
# the fail-open path below is unchanged: an unvalidated buffer still gets the
# probe, and a host without jq still reaches the 127 branch.
if ((validated == 0)) && command -v jq >/dev/null 2>&1; then
# `printf | jq`, not a here-string — see hook::json_complete: a here-string
# at or above the pipe capacity deadlocks the shell before jq is exec'd, and
# a hook payload routinely exceeds it.
printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$?
fi
if ((jq_rc != 0 && jq_rc != 127)); then
if ((stalled)); then
echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2
return 2
fi
# Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty
# payload, not a malformed one — keep the silent rc=1 path advisory hooks
# treat as a no-op.
[[ -n "${input//[[:space:]]/}" ]] || return 1
echo "BLOCKED: hook stdin is not valid JSON." >&2
return 2
fi
printf '%s' "$input"
}
# Extract a single jq field from a buffered input string. CR-stripped. Returns 1
# when the field is empty or jq fails, so the caller can skip.
#
# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe
# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks
# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which
# routinely exceeds that.
# FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0
hook::jq_field() {
local field
field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null)
[[ -n "$field" ]] || return 1
printf '%s' "$field"
}
# Extract SEVERAL fields from one buffered input in ONE jq process. A hook that
# reads three fields with hook::jq_field pays three forks and three execs over
# the SAME stdin envelope — ~840 ms on Windows Git Bash, per invocation, for
# work jq does once. Results land in the HOOK_JQ_FIELDS array, index-parallel to
# the filters.
#
# An absent, null, or empty field becomes the EMPTY STRING and keeps its slot:
# `// empty` (what hook::jq_field uses, where "empty means the caller skips" is
# the whole contract) would DROP the field here and silently shift every later
# index onto the wrong filter. Emptiness stays the caller's decision.
#
# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or zero filters were
# requested. Returns 2 when jq is present but cannot parse the payload, or when
# the record count does not match what was asked for in EITHER direction (an
# over-count rejects too: two concatenated well-formed JSON documents parse fine
# and yield twice the records). Advisory callers allow on both codes; a blocking
# guard branches on the codes and exits 2 on rc 2 (#2157). An advisory caller
# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL
# CONTENT cannot (#2122). A payload jq itself rejects — malformed JSON, a
# wrongly typed field, an empty buffer — still can, and process substitution
# means jq's own exit status is not observed either. Values are CR-stripped, as
# in hook::jq_field.
#
# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried
# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can