-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathhook-utils.sh
More file actions
4851 lines (4711 loc) · 223 KB
/
Copy pathhook-utils.sh
File metadata and controls
4851 lines (4711 loc) · 223 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.
#
# CALLING CONVENTION: a helper that produces a value is spelled
# `hook::<name>_to <var> [args…]` and writes into the caller's variable. That
# is the one convention; call it directly rather than wrapping it in `$( )`.
# GNU Bash forks a subshell for every command substitution even when the body
# is only builtins (Command Substitution, Bash Reference Manual;
# https://mywiki.wooledge.org/CommandSubstitution), and on Windows Git Bash a
# fork is a non-copy-on-write Win32 CreateProcess costing milliseconds, so a
# capture around a `_to` helper is pure loss on hooks that run per edit.
# Six helpers still print instead — hook::json_escape, hook::physical_path,
# hook::repo_root, hook::buffer_stdin, hook::raw_file_path and
# hook::read_file_path (which reads fd0; a hook holding the buffered payload
# calls hook::read_file_path_to). Each carries, at its definition, the reason
# its print form is kept.
# Guard against double-sourcing.
[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0
readonly _HOOK_UTILS_LOADED=1
# Per-hook kill switch via the plugin's <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_to <var> <string> writes in THIS shell and is the form to
# call. hook::json_escape is kept as a print form for the two Stop/PostCompact
# marker builders that splice an escaped field into a single-quoted JSON
# literal, where a capture is the shape; a `$(hook::json_escape …)` capture is
# still a fork even when the body is only builtins (Command Execution
# Environment, Bash Reference Manual;
# https://mywiki.wooledge.org/CommandSubstitution). The previous
# `printf | tr -d` pipeline added two more process creations and a `tr` exec
# per notice; Cygwin's fork is a non-copy-on-write Win32 CreateProcess
# (Cygwin User's Guide, "Process Creation": "fork will almost certainly
# always be inefficient under Win32"). Residual C0 deletion is the same
# character class tr used (`\001-\010\013\014\016-\037`); NUL cannot appear
# in a bash string, so tr's `\000` was already unrepresentable.
hook::json_escape_to() {
local __hu_s="$2"
__hu_s="${__hu_s//\\/\\\\}"
__hu_s="${__hu_s//\"/\\\"}"
__hu_s="${__hu_s//$'\n'/\\n}"
__hu_s="${__hu_s//$'\r'/\\r}"
__hu_s="${__hu_s//$'\t'/\\t}"
if [[ "$__hu_s" == *[[:cntrl:]]* ]]; then
__hu_s="${__hu_s//[$'\001'-$'\010'$'\013'$'\014'$'\016'-$'\037']/}"
fi
printf -v "$1" '%s' "$__hu_s"
}
hook::json_escape() {
local __hu_e
hook::json_escape_to __hu_e "$1"
printf '%s' "$__hu_e"
}
# Emit hook JSON carrying an agent-channel context (additionalContext) and/or a
# user-channel message (systemMessage) as ONE document — 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. One entry point for both
# channels, so a hook with only one of them passes "" for the other rather
# than picking between two spellings of the same emit.
# hook::emit_channels PostToolUse "$ctx" "$sysmsg"
# hook::emit_channels PreToolUse "$ctx" "" # agent channel only
hook::emit_channels() {
local event="$1" ctx="$2" sysmsg="$3"
[[ -n "$ctx" || -n "$sysmsg" ]] || return 0
local __hu_ee __hu_ec __hu_es out="{"
if [[ -n "$ctx" ]]; then
hook::json_escape_to __hu_ee "$event"
hook::json_escape_to __hu_ec "$ctx"
out+='"hookSpecificOutput":{"hookEventName":"'"$__hu_ee"'","additionalContext":"'"$__hu_ec"'"}'
[[ -n "$sysmsg" ]] && out+=","
fi
if [[ -n "$sysmsg" ]]; then
hook::json_escape_to __hu_es "$sysmsg"
out+='"systemMessage":"'"$__hu_es"'"'
fi
out+="}"
hook::emit_document "$out"
}
# hook::emit_document <json>: write ONE hook JSON document to stdout. Every
# document a hook emits goes through here, hook::emit_channels included, so a
# dispatcher that runs several hooks in one process (guardrails run-guards.sh)
# can override this one function to collect the documents and merge them,
# instead of capturing each hook's stdout in a subshell. A hook that prints a
# document with its own printf bypasses that collection: under such a
# dispatcher its document reaches stdout unmerged, which is invalid hook
# output when another hook emitted too.
hook::emit_document() {
printf '%s\n' "$1"
}
# 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
if [[ "${HOOK_NOTICE_KEEP_BODY:-0}" == "1" ]]; then
# A prerequisite renewal keeps the install route (#4240).
if [[ -n "${HOOK_NOTICE_COUNT:-}" ]]; then
msg="${msg}"$'\n'"[${HOOK_NOTICE_COUNT} skips this session]"
fi
else
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
fi
hook::emit_channels "$event" "$msg" "$msg"
}
# The exit-0 notice for hook::buffer_stdin rc 3 (a JSON payload cut short by
# the pipe CLOSING mid-document; a pipe that stalls on such a prefix is rc 2
# and never reaches this). Not once-per-session: each occurrence is one tool
# call that ran unevaluated, and the user is the one who can act on a starved
# host. Same text on stderr (next to buffer_stdin's own diagnostic) and on
# both hook channels. <event> may be empty when the caller does not know the
# hook event — the dispatcher runs under several — in which case only
# systemMessage is emitted, since hookSpecificOutput requires the event name.
# The caller exits 0 right after.
# hook::stdin_cut_short_notice PreToolUse "guardrails block-hook-bypass"
hook::stdin_cut_short_notice() {
local event="$1" label="$2"
local msg="$label: hook stdin was cut short (the payload pipe closed mid-document), so this tool call was not evaluated and is allowed through. That is a transport fault on this host, not a property of the command, and it says nothing about what would run. If it recurs the host is starved; see the stdin_read_timeout option and the recorded hook-run durations."
echo "$msg" >&2
if [[ -n "$event" ]]; then
hook::emit_channels "$event" "$msg" "$msg"
else
hook::emit_channels "" "" "$msg"
fi
}
# 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:-}}"
if [[ -z "$raw" || "$raw" == '<unset>' ]]; then
printf '%s' '<unset>'
return 0
fi
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_KEEP_BODY=0
HOOK_NOTICE_RENEW_EVERY="${HOOK_NOTICE_RENEW_EVERY:-8}"
hook::notice_once() {
local key="$1" input="${2:-}" class="${3:-}" session="no-session" agent="no-agent"
local session_only=0
HOOK_NOTICE_KIND=full
HOOK_NOTICE_COUNT=0
HOOK_NOTICE_KEEP_BODY=0
[[ "$class" == "prerequisite" ]] && session_only=1
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"
[[ "$session_only" -eq 1 ]] && agent="session"
local dir="${CLAUDE_PLUGIN_DATA:-}"
[[ -n "$dir" ]] || return 0
dir="$dir/skip-notices"
# `-d` before `mkdir -p`: the directory exists after the first skip in this
# process, and mkdir is an external command. Same placement as other
# once-per-process probes in this file.
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir" 2>/dev/null || return 0
fi
# Prune once per hook process. `find -mtime +7 -delete` on every notice
# was one exec per skip; the process is short-lived and the markers are
# tiny, so repeating the walk inside one fire cannot find newly-stale
# files. `_HOOK_NOTICE_PRUNED` is unset in a fresh hook process.
if [[ -z "${_HOOK_NOTICE_PRUNED:-}" ]]; then
find "$dir" -type f -mtime +7 -delete 2>/dev/null || true
_HOOK_NOTICE_PRUNED=1
fi
local marker="$dir/${key}.${session}.${agent}"
local count=0
if [[ -f "$marker" ]]; then
# `read`, not `tr -d '[:space:]'`: that substitution was a fork plus a
# tr exec to strip whitespace bash can already delete in-process.
IFS= read -r count <"$marker" || true
count="${count//[[:space:]]/}"
[[ "$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
[[ "$session_only" -eq 1 ]] && HOOK_NOTICE_KEEP_BODY=1
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.
# hook::raw_file_path_to RAW_FILE "$INPUT" || exit 0
# <var> is left unchanged on a return of 1.
hook::raw_file_path_to() {
[[ "$2" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]] || return 1
[[ -n "${BASH_REMATCH[1]}" ]] || return 1
printf -v "$1" '%s' "${BASH_REMATCH[1]}"
}
# Print form, kept for the hooks that capture it once at top level
# (instruction-placement's index-drift hook), off the per-edit prologue.
# RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0
hook::raw_file_path() {
local __hu_raw
hook::raw_file_path_to __hu_raw "$1" || return 1
printf '%s' "$__hu_raw"
}
# ============================================================================
# THE jq GATE — TWO POSTURES, AND WHY THERE ARE TWO (#2146)
# ============================================================================
#
# A hook that cannot parse its payload has exactly two honest moves: let the
# tool call through (fail OPEN) or deny it (fail CLOSED). This library offers
# both, as two named functions, and the choice belongs to the CALLING hook. The
# reasoning lives HERE, at the decision point, not only at the call sites —
# before #2146 every call site asserted a posture in a comment and nothing where
# the posture is actually implemented explained it.
#
# hook::require_jq fails OPEN — the default, and correct for most hooks
# hook::require_jq_blocking fails CLOSED — for a guard that blocks an
# irreversible operation
#
# WHY FAIL OPEN IS THE DEFAULT. Most hooks in this marketplace are advisory or
# cosmetic: a formatter, a lint pass, a context injector, a detect-then-judge
# oracle. Their finding is a prompt, not a verdict. Blocking a user's tool call
# because an OPTIONAL formatting hook could not find an OPTIONAL dependency
# inverts the cost: the guard's job is worth less than the work it would stop.
# The once-per-session notice is what keeps that degradation honest rather than
# silent — the user and the agent are both told the hook is off.
#
# WHY A MINORITY MUST FAIL CLOSED. A guard whose job is to stop an IRREVERSIBLE
# operation cannot be a suggestion. Its whole value is that it is there when
# nobody is watching, and "somewhere without jq" is not an exotic state — it is
# the default state of a machine that has not installed one dependency. A guard
# that a missing dependency silently switches off is not a guard; it is a guard
# on machines that happen to be configured for it. #2146 measured this: with jq
# unreachable, `git push --force origin main` was ALLOWED by block-dangerous-git,
# after one notice, for the rest of the session.
#
# WHICH HOOKS ARE IN THAT MINORITY — the criterion is mechanical, and it is
# INTERNAL CONSISTENCY, not a taste judgment about severity. A hook belongs in
# the fail-closed class iff it ALREADY fails closed on some other
# "I cannot parse this input" condition. 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 behavior) means a guard that should fail closed but whose flag someone
# forgot fails open SILENTLY — which is the exact defect class #2146 reports,
# reintroduced at the API. Two names make the posture greppable, make the
# fail-closed path impossible to reach by accident, and make omission a visible
# choice instead of an invisible default.
# Fail-OPEN 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 honors
# 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.
#
# Every path helper below is spelled `hook::<name>_to <var> <path>` and stores
# its answer in the caller's variable: one convention, no per-call-site choice.
# The `_to` helpers keep their locals under a `__hu_` prefix so the caller's
# variable name cannot collide with them.
hook::normalize_path_to() {
local __hu_p="${2//\\//}"
case "${OSTYPE:-}" in
msys* | cygwin* | win32)
if [[ "$__hu_p" =~ ^/([a-zA-Z])/ || "$__hu_p" =~ ^([a-zA-Z]):/ ]]; then
local __hu_rest="${__hu_p:2}"
printf -v "$1" '%s' "${BASH_REMATCH[1]^}:${__hu_rest,,}"
return 0
fi
;;
*) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below
esac
printf -v "$1" '%s' "$__hu_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_to() {
local __hu_p="$2"
case "${OSTYPE:-}" in
msys* | cygwin* | win32) ;;
*)
printf -v "$1" '%s' "$__hu_p"
return 0
;;
esac
if [[ "$__hu_p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then
local __hu_plain __hu_long
if __hu_plain=$(cygpath -m -- "$__hu_p" 2>/dev/null) &&
__hu_long=$(cygpath -l -m -- "$__hu_p" 2>/dev/null) &&
[[ -n "$__hu_long" && "$__hu_long" != "$__hu_plain" ]]; then
printf -v "$1" '%s' "$__hu_long"
return 0
fi
fi
printf -v "$1" '%s' "$__hu_p"
}
# hook::_physical_builtin_to <var> <path>...
# realpath's answer for every <path>, one per line, with no realpath process:
# one subshell reads the physical form with `cd -P` (a directory, or a file's
# directory plus its name). Linux only, and only for the shapes where the two
# agree: every path absolute with no `//`, each an existing directory or an
# existing non-symlink file whose name is not `.` or `..`, and every `cd -P`
# succeeding. Anything else returns 1 and the caller runs realpath as before:
# a symlinked file, a missing path, a relative one, a directory it cannot
# enter. Git Bash and macOS keep realpath, whose drive and short-name forms
# this does not reproduce. `builtin cd`, so an exported `cd` function cannot
# answer for it.
hook::_physical_builtin_to() {
[[ "${OSTYPE:-}" == linux* ]] || return 1
local __hu_pb_dest="$1" __hu_pb_p __hu_pb_out
shift
for __hu_pb_p in "$@"; do
[[ "$__hu_pb_p" == /* && "$__hu_pb_p" != *//* && "$__hu_pb_p" != *$'\n'* ]] || return 1
done
__hu_pb_out=$(
for __hu_pb_p in "$@"; do
if [[ -d "$__hu_pb_p" ]]; then
builtin cd -P -- "$__hu_pb_p" 2>/dev/null || exit 1
printf '%s\n' "$PWD"
else
[[ -e "$__hu_pb_p" && ! -L "$__hu_pb_p" ]] || exit 1
__hu_pb_d=${__hu_pb_p%/*} __hu_pb_b=${__hu_pb_p##*/}
[[ -n "$__hu_pb_b" && "$__hu_pb_b" != . && "$__hu_pb_b" != .. ]] || exit 1
builtin cd -P -- "${__hu_pb_d:-/}" 2>/dev/null || exit 1
printf '%s\n' "${PWD%/}/$__hu_pb_b"
fi
done
) || return 1
printf -v "$__hu_pb_dest" '%s' "$__hu_pb_out"
}
# 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_to() {
local __hu_r=""
HOOK_PHYSICAL_PATH_UNRESOLVED=0
if hook::_physical_builtin_to __hu_r "$2"; then
hook::expand_8dot3_to "$1" "$__hu_r"
return 0
fi
if __hu_r=$(realpath -- "$2" 2>/dev/null) || __hu_r=$(readlink -f -- "$2" 2>/dev/null); then
if [[ -n "$__hu_r" ]]; then
hook::expand_8dot3_to "$1" "$__hu_r"
return 0
fi
fi
# No cygpath fallback: cygpath converts spellings but does not follow
# symlinks, and guards that fail closed on this return would exempt a temp
# symlink pointing into the repository.
HOOK_PHYSICAL_PATH_UNRESOLVED=1
printf -v "$1" '%s' "$2"
return 1
}
# Kept as a print form because markdown-format's directory walk resolves each
# queue entry inline inside an array append, where a capture is the shape.
# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED
hook::physical_path() {
local __hu_v __hu_rc=0
hook::physical_path_to __hu_v "$1" || __hu_rc=$?
printf '%s' "$__hu_v"
return "$__hu_rc"
}
# --- Per-process physical-path cache --------------------------------------
# The membership guard resolves the same few directories on every call: the
# project root and this host's temp roots. Their physical form does not change
# within one hook process, so each spelling is resolved once and remembered in
# three parallel indexed arrays (keys, values, resolver status). Plain arrays,
# not an associative array, so the cache runs on Bash 3.2. Only directories are
# meant to live here for a process lifetime; hook::read_file_path forgets the
# edited file's entry as soon as it has read it, so a later call in the same
# process sees the file as it is then.
_HOOK_PHYS_KEYS=()
_HOOK_PHYS_VALS=()
_HOOK_PHYS_RCS=()
_HOOK_PHYS_I=-1
# hook::_phys_cache_index <path>: sets _HOOK_PHYS_I to the slot holding <path>,
# returns 1 when it is not cached. A forgotten slot has an empty key, which no
# real path can match.
hook::_phys_cache_index() {
local __hu_i
[[ -n "$1" ]] || return 1
for ((__hu_i = 0; __hu_i < ${#_HOOK_PHYS_KEYS[@]}; __hu_i++)); do
if [[ "${_HOOK_PHYS_KEYS[__hu_i]}" == "$1" ]]; then
_HOOK_PHYS_I=$__hu_i
return 0
fi
done
return 1
}
hook::_phys_cache_forget() {
hook::_phys_cache_index "$1" && _HOOK_PHYS_KEYS[_HOOK_PHYS_I]=""
return 0
}
# hook::_physical_cached_to <var> <path>: hook::physical_path_to through the
# cache. Same value, same return status, same HOOK_PHYSICAL_PATH_UNRESOLVED as
# the uncached call; a miss resolves the one path and stores it.
# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED
hook::_physical_cached_to() {
if hook::_phys_cache_index "$2"; then
printf -v "$1" '%s' "${_HOOK_PHYS_VALS[_HOOK_PHYS_I]}"
HOOK_PHYSICAL_PATH_UNRESOLVED=${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}
return "${_HOOK_PHYS_RCS[_HOOK_PHYS_I]}"
fi
local __hu_v __hu_rc=0
hook::physical_path_to __hu_v "$2" || __hu_rc=$?
if [[ -n "$2" ]]; then
_HOOK_PHYS_KEYS+=("$2")
_HOOK_PHYS_VALS+=("$__hu_v")
_HOOK_PHYS_RCS+=("$__hu_rc")
fi
printf -v "$1" '%s' "$__hu_v"
return "$__hu_rc"
}
# hook::_physical_prime <path>...: resolve every uncached path with ONE
# realpath process and store the answers. realpath resolves each argument on
# its own and prints one line per argument in argument order, so the batch
# answer for a path is the same string the per-path call returns; the 8.3
# expansion is applied per answer exactly as hook::physical_path_to does. Any
# doubt (a path carrying a newline, a non-zero exit, a line count that does not
# match) stores nothing and leaves the per-path resolver to answer lazily, so
# the batch can only ever save work, never change an answer. readlink -f hosts
# (no realpath) skip the batch for the same reason.
hook::_physical_prime() {
local -a __hu_todo=()
local __hu_p __hu_out __hu_i __hu_seen="" __hu_v
for __hu_p in "$@"; do
[[ -n "$__hu_p" ]] || continue
[[ "$__hu_p" == *$'\n'* ]] && return 0
case "$__hu_seen" in
*"|$__hu_p|"*) continue ;;
*) ;; # first sighting
esac
__hu_seen="$__hu_seen|$__hu_p|"
hook::_phys_cache_index "$__hu_p" && continue
__hu_todo+=("$__hu_p")
done
((${#__hu_todo[@]} > 1)) || return 0
if ! hook::_physical_builtin_to __hu_out "${__hu_todo[@]}"; then
command -v realpath >/dev/null 2>&1 || return 0
# portability-ok: realpath with several operands is GNU and BSD alike; a host whose realpath rejects it fails the exit-status check and falls back per path
__hu_out=$(realpath -- "${__hu_todo[@]}" 2>/dev/null) || return 0
fi
local -a __hu_lines=()
local __hu_glob=0
[[ $- == *f* ]] || __hu_glob=1
set -f
local IFS=$'\n'
# shellcheck disable=SC2206 # splitting realpath's one-line-per-operand output is the intent
__hu_lines=($__hu_out)
((__hu_glob)) && set +f
((${#__hu_lines[@]} == ${#__hu_todo[@]})) || return 0
for ((__hu_i = 0; __hu_i < ${#__hu_todo[@]}; __hu_i++)); do
[[ -n "${__hu_lines[__hu_i]}" ]] || return 0
hook::expand_8dot3_to __hu_v "${__hu_lines[__hu_i]}"
_HOOK_PHYS_KEYS+=("${__hu_todo[__hu_i]}")
_HOOK_PHYS_VALS+=("$__hu_v")
_HOOK_PHYS_RCS+=(0)
done
return 0
}
# The temp-root candidates hook::under_temp_root compares against: the
# environment's own answer (TMPDIR/TMP/TEMP) plus the POSIX defaults, never a
# hardcoded platform assumption. Existing directories only, each spelling
# once, in _HOOK_TEMP_CANDS.
#
# On a Windows bash (msys, cygwin, win32) with cygpath, the drive spellings of
# those candidates follow them: Cygwin bash reports TEMP and TMP as `/tmp`, so
# without cygpath no candidate is spelled `C:/...` and a drive-spelled target
# never matches. The POSIX spellings stay, because a `/tmp/...` target must
# still match. The long form (`cygpath -l -m`) comes before the mixed form
# (`cygpath -m`) when the two differ, so a long target matches without
# resolving an 8.3 spelling. The drive spellings are looked up once per process
# for a given candidate list: one cygpath process, plus a second only when a
# mixed answer carries an 8.3 `~`. A cygpath that fails, or answers with the
# wrong number of lines, adds nothing, which leaves the POSIX candidates as
# they were. `--no-drive` stops after the environment and POSIX spellings, so a
# caller's lexical pre-match spawns no process.
_HOOK_TEMP_CANDS=()
_HOOK_TEMP_WIN_KEY=""
_HOOK_TEMP_WIN=()
hook::_temp_root_candidates() {
local __hu_cand __hu_seen=""
_HOOK_TEMP_CANDS=()
for __hu_cand in "${TMPDIR:-}" "${TMP:-}" "${TEMP:-}" /tmp /var/tmp; do
[[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue
case "$__hu_seen" in
*"|$__hu_cand|"*) continue ;;
*) ;; # first sighting of this candidate
esac
__hu_seen="$__hu_seen|$__hu_cand|"
_HOOK_TEMP_CANDS+=("$__hu_cand")
done
[[ "${1:-}" == --no-drive ]] && return 0
case "${OSTYPE:-}" in
msys* | cygwin* | win32) ;;
*) return 0 ;; # POSIX hosts: the candidates above are the whole set
esac
((${#_HOOK_TEMP_CANDS[@]})) || return 0
if [[ "$_HOOK_TEMP_WIN_KEY" != "$__hu_seen" ]]; then
hook::_temp_win_spellings "${_HOOK_TEMP_CANDS[@]}" || return 0
_HOOK_TEMP_WIN_KEY=$__hu_seen
fi
for __hu_cand in ${_HOOK_TEMP_WIN[@]+"${_HOOK_TEMP_WIN[@]}"}; do
[[ -n "$__hu_cand" && -d "$__hu_cand" ]] || continue
case "$__hu_seen" in
*"|$__hu_cand|"*) continue ;;
*) ;; # first sighting of this spelling
esac
__hu_seen="$__hu_seen|$__hu_cand|"
_HOOK_TEMP_CANDS+=("$__hu_cand")
done
}
# hook::_split_lines <text> <count>: <text> split on newlines into the
# caller's __hu_lines array. Returns 1 unless it held exactly <count> non-empty
# lines.
hook::_split_lines() {
local __hu_glob=0 IFS=$'\n'
[[ $- == *f* ]] || __hu_glob=1
set -f
# shellcheck disable=SC2206 # splitting one-line-per-operand output is the intent
__hu_lines=($1)
((__hu_glob)) && set +f
((${#__hu_lines[@]} == $2))
}
# hook::_temp_win_spellings <candidate>...: the drive spellings of every
# candidate in _HOOK_TEMP_WIN, long forms first. Returns 1, storing nothing,
# when there is no cygpath or the mixed-form answer cannot be trusted.
hook::_temp_win_spellings() {
local __hu_out __hu_i
local -a __hu_lines=() __hu_mixed=() __hu_tilde=()
command -v cygpath >/dev/null 2>&1 || return 1
for __hu_out in "$@"; do
[[ "$__hu_out" == *$'\n'* ]] && return 1
done
__hu_out=$(cygpath -m -- "$@" 2>/dev/null) || return 1
hook::_split_lines "$__hu_out" "$#" || return 1
__hu_mixed=("${__hu_lines[@]}")
_HOOK_TEMP_WIN=()
for __hu_out in "${__hu_mixed[@]}"; do
if [[ "$__hu_out" == *~* ]]; then __hu_tilde+=("$__hu_out"); fi
done
if ((${#__hu_tilde[@]})) &&
__hu_out=$(cygpath -l -m -- "${__hu_tilde[@]}" 2>/dev/null) &&
hook::_split_lines "$__hu_out" "${#__hu_tilde[@]}"; then
for ((__hu_i = 0; __hu_i < ${#__hu_tilde[@]}; __hu_i++)); do
if [[ "${__hu_lines[__hu_i]}" != "${__hu_tilde[__hu_i]}" ]]; then
_HOOK_TEMP_WIN+=("${__hu_lines[__hu_i]}")
fi
done
fi
_HOOK_TEMP_WIN+=("${__hu_mixed[@]}")
}
# 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 a Windows bash the temp tree is `/tmp` and also a `C:/...`
# path, and `realpath` resolves the drive form to a drive path while leaving
# `/tmp` as `/tmp`. The environment does not reliably carry the drive form
# (Cygwin bash reports TEMP and TMP as `/tmp`), so the candidate list adds it
# through cygpath (see hook::_temp_root_candidates). 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, and each
# candidate's physical form is remembered for the process (see the cache
# above), so a second call costs no resolver process.
#
# Raw-spelling shortcut: when the caller vouches that <normalized-path> is a
# PHYSICAL path (_HOOK_UTR_TARGET_PHYSICAL=1, set only by hook::read_file_path
# after the resolver succeeded), a candidate whose raw normalized spelling is
# already a prefix of the target is a match without resolving it. A physical
# path contains no symlink component, so a candidate that spells one of its
# prefixes names a chain of real directories whose physical form is that same
# prefix; the resolver would answer the same. A candidate that does not match
# raw is still resolved, so a symlinked temp root (macOS /tmp) is found the
# way it always was. Without the vouch the shortcut is off and every candidate
# is resolved, exactly as before. Note that hook::read_file_path primes every
# candidate into the cache with one batched realpath before calling here, so
# on that path the shortcut skips a lookup, not a process; the batch is what
# saves the processes. A direct caller without the batch saves the resolver.
#
# On a Windows Git Bash host (msys, cygwin, win32) the target goes through
# hook::normalize_path_to like the candidates, so its `/c/...` and `C:/...`
# spellings both compare. A POSIX host leaves it untouched: `\` is a filename
# byte there, and folding it could make a root-level `tmp\x` compare as under
# `/tmp`. Precondition: the target is already lexically normalized (as
# block-hook-bypass's _norm_path produces) or physically resolved, with no `..`
# segment; nothing here resolves `..`.
# hook::under_temp_root "$norm_path" && ...
_HOOK_UTR_TARGET_PHYSICAL=0
hook::under_temp_root() {
local target="$1" cand norm phys
case "${OSTYPE:-}" in
msys* | cygwin* | win32) hook::normalize_path_to target "$target" ;;
*) ;; # POSIX hosts: compare the target as given
esac
hook::_temp_root_candidates
for cand in ${_HOOK_TEMP_CANDS[@]+"${_HOOK_TEMP_CANDS[@]}"}; do
if ((_HOOK_UTR_TARGET_PHYSICAL)); then
hook::normalize_path_to norm "$cand"
if [[ "$norm" != / ]]; then
norm="${norm%/}"
if [[ -n "$norm" && ("$target" == "$norm" || "$target" == "$norm"/*) ]]; then
return 0
fi
fi
fi
hook::_physical_cached_to phys "$cand" || :
hook::normalize_path_to norm "$phys"
# 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
}
# True when the repository enclosing <file> gitignores it (hook-precision rule
# 6, #4671). A rewrite of an ignored file has no `git checkout` to undo it.
#
# `git check-ignore` consults the index unless --no-index is passed, so a
# TRACKED file matching an ignore pattern reads as not ignored: a file under
# version control is part of the reviewable artifact whatever the patterns say.
# Exit 0 = ignored, 1 = not ignored, 128 = error; only 0 answers true.
# https://git-scm.com/docs/git-check-ignore (fetched 2026-09-28)
#
# FAILS TOWARD ACTING. Git absent, the directory gone, no repository, or a
# check-ignore error all answer false, so the hook runs as before. A skip that
# fired on an error would disable the hook invisibly and repo-wide.
#
# Git's repository-selection environment is cleared: an inherited
# GIT_DIR/GIT_WORK_TREE from a wrapper that launched the session would make
# another repository answer, and a linked worktree under a path its parent
# ignores (`.claude/worktrees/**`) would read every file as ignored. The
# check runs from the file's own directory with a `./<base>` spelling, so no
# path translation is needed on Windows Git Bash.
# hook::file_is_gitignored "$FILE" && ...
hook::file_is_gitignored() {
local file="$1" dir base
dir="${file%/*}" base="${file##*/}"
command -v git >/dev/null 2>&1 || return 1
[[ -n "$base" && "$dir" != "$file" ]] || return 1
(
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_CEILING_DIRECTORIES \
GIT_DISCOVERY_ACROSS_FILESYSTEM
cd "${dir:-/}" 2>/dev/null || exit 1
git check-ignore -q -- "./$base" 2>/dev/null
)
}
# True when the hook should leave <file> alone because the repository ignores
# it and the plugin's `<plugin>_lint_gitignored` opt-in, passed as <opt-in>
# from its CLAUDE_PLUGIN_OPTION_* mirror, is not the string "true". Any other
# value, including garbage, reads as the manifest default (false).
# hook::gitignored_out_of_scope "${CLAUDE_PLUGIN_OPTION_X_LINT_GITIGNORED:-false}" "$FILE" && emit_skipped
hook::gitignored_out_of_scope() {
[[ "$1" == "true" ]] && return 1
hook::file_is_gitignored "$2"
}
# --- Builtin JSON helpers ----------------------------------------------------
# hook::read_file_path and hook::emit_telemetry used to spend a jq process each
# on every hook run. On Windows Git Bash one spawn costs tens of milliseconds,
# so the helpers here handle the well-formed shapes those two functions see in
# practice with shell builtins and hand anything they cannot PROVE to the
# unchanged jq path. The contract is "the bytes jq would produce, or fall
# back", never "close enough".
#
# What the builtin paths rely on: the text is valid JSON. Every hook payload
# reaching hook::read_file_path has passed hook::buffer_stdin's validation
# (jq's, or hook::_json_object_proven's for an object the skeleton below
# accepts), and every telemetry data object is built with jq. The helpers
# verify the structure they walk (terminated strings, well-formed tokens,
# properly nested brackets, one root, no raw control bytes inside strings,
# only escapes jq accepts). What they do not check is jq's parser depth limit
# (jq 1.8.2 rejects 9999 nested levels), which no hook payload comes near.
#
# Why no scanning: on this repo's Windows hosts bash regex matching and the
# `%%`/`##` pattern operators cost about a microsecond per character, so a
# 60 KB Write payload would cost more than the jq process they replace. The
# only string operations used on the whole payload are literal-substring
# replacement, `[[ == *x* ]]` containment, IFS word splitting and offset
# slicing, all of which run at C speed.
#
# They run in the C locale. Under a UTF-8 locale bash does those operations
# per multibyte character, and the cost grows faster than the payload: 297 ms
# for a 37 KB Edit payload against about 8 ms under C, on WSL. In C one
# character is one byte, so the offsets and lengths recorded while splitting
# are byte counts, and the bodies sliced with them are the payload's own bytes;
# a non-ASCII byte is ordinary string data, as JSON allows. The entry points
# (hook::_fast_file_path_to, hook::_fast_fields, hook::json_compact_to,
# hook::_json_object_proven) re-enter through hook::_c_locale, so every
# skeleton, and the cached one, is built under the same locale.
# hook::_c_locale <command> [args...]
# Run <command> with LC_ALL=C and put the caller's LC_ALL back afterwards,
# exported or not, set or unset, whatever <command> returned. An explicit save