agent-relay-runner 0.62.2 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,689 @@
1
+ #!/usr/bin/env bash
2
+ # BEST-EFFORT GUARDRAIL / defense-in-depth only. This hook denies common
3
+ # mutating Bash patterns for read-only Claude sessions, but it is not a sandbox.
4
+ # Real boundaries are Relay token scope, unavailable write tools, and future
5
+ # OS-level enforcement.
6
+ set -euo pipefail
7
+ source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/hooks/relay-status.sh"
8
+ relay_install_hook_guard read-only-bash-guard
9
+
10
+ payload="$(cat)"
11
+
12
+ emit_deny() {
13
+ local reason="${1:-Read-only Claude sessions may only run inspection commands.}"
14
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' \
15
+ "$(relay_json_escape "$reason")"
16
+ }
17
+
18
+ case "${AGENT_RELAY_APPROVAL:-}" in
19
+ open|guarded)
20
+ exit 0
21
+ ;;
22
+ read-only)
23
+ ;;
24
+ *)
25
+ # Unknown approval values fail closed so typos such as "readonly" or
26
+ # casing drift do not silently disable the read-only boundary.
27
+ ;;
28
+ esac
29
+
30
+ if ! command -v jq >/dev/null 2>&1; then
31
+ emit_deny "Read-only Bash guard could not inspect the command because jq is unavailable."
32
+ exit 0
33
+ fi
34
+
35
+ tool_name="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null || true)"
36
+ if [ "${tool_name,,}" != "bash" ]; then
37
+ exit 0
38
+ fi
39
+
40
+ command_text="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
41
+ if [ -z "$command_text" ]; then
42
+ emit_deny "Read-only Bash guard denied an empty Bash command."
43
+ exit 0
44
+ fi
45
+
46
+ shopt -s extglob
47
+
48
+ DENY_REASON=""
49
+ SEGMENTS=()
50
+ WORDS=()
51
+
52
+ deny_segment() {
53
+ DENY_REASON="$1"
54
+ return 1
55
+ }
56
+
57
+ trim_shell_text() {
58
+ local value="$1"
59
+ value="${value##+([[:space:]])}"
60
+ value="${value%%+([[:space:]])}"
61
+ printf '%s' "$value"
62
+ }
63
+
64
+ hard_deny_reason() {
65
+ local text="$1"
66
+ case "$text" in
67
+ *'$('*|*'`'*)
68
+ printf '%s' "Read-only Bash guard denied command substitution."
69
+ return 0
70
+ ;;
71
+ *'<('*|*'>('*)
72
+ printf '%s' "Read-only Bash guard denied process substitution."
73
+ return 0
74
+ ;;
75
+ esac
76
+
77
+ local i len ch next quote="" escaped=0
78
+ len=${#text}
79
+ for ((i = 0; i < len; i++)); do
80
+ ch="${text:i:1}"
81
+ if (( escaped )); then
82
+ escaped=0
83
+ continue
84
+ fi
85
+ if [ -n "$quote" ]; then
86
+ if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
87
+ escaped=1
88
+ continue
89
+ fi
90
+ if [ "$ch" = "$quote" ]; then
91
+ quote=""
92
+ fi
93
+ continue
94
+ fi
95
+
96
+ case "$ch" in
97
+ "'"|'"')
98
+ quote="$ch"
99
+ ;;
100
+ "\\")
101
+ escaped=1
102
+ ;;
103
+ ">")
104
+ printf '%s' "Read-only Bash guard denied shell output redirection."
105
+ return 0
106
+ ;;
107
+ "<")
108
+ next="${text:i+1:1}"
109
+ if [ "$next" = ">" ]; then
110
+ printf '%s' "Read-only Bash guard denied shell output redirection."
111
+ return 0
112
+ fi
113
+ ;;
114
+ esac
115
+ done
116
+
117
+ return 1
118
+ }
119
+
120
+ split_segments() {
121
+ local text="$1"
122
+ local current="" i len ch next quote="" escaped=0
123
+ SEGMENTS=()
124
+ len=${#text}
125
+ for ((i = 0; i < len; i++)); do
126
+ ch="${text:i:1}"
127
+ if (( escaped )); then
128
+ current+="$ch"
129
+ escaped=0
130
+ continue
131
+ fi
132
+ if [ -n "$quote" ]; then
133
+ current+="$ch"
134
+ if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
135
+ escaped=1
136
+ continue
137
+ fi
138
+ if [ "$ch" = "$quote" ]; then
139
+ quote=""
140
+ fi
141
+ continue
142
+ fi
143
+
144
+ case "$ch" in
145
+ "'"|'"')
146
+ quote="$ch"
147
+ current+="$ch"
148
+ ;;
149
+ "\\")
150
+ escaped=1
151
+ current+="$ch"
152
+ ;;
153
+ $'\n'|";")
154
+ SEGMENTS+=("$current")
155
+ current=""
156
+ ;;
157
+ "&")
158
+ next="${text:i+1:1}"
159
+ if [ "$next" = "&" ]; then
160
+ ((i += 1))
161
+ fi
162
+ SEGMENTS+=("$current")
163
+ current=""
164
+ ;;
165
+ "|")
166
+ next="${text:i+1:1}"
167
+ if [ "$next" = "|" ]; then
168
+ ((i += 1))
169
+ fi
170
+ SEGMENTS+=("$current")
171
+ current=""
172
+ ;;
173
+ *)
174
+ current+="$ch"
175
+ ;;
176
+ esac
177
+ done
178
+
179
+ if [ -n "$quote" ] || (( escaped )); then
180
+ return 1
181
+ fi
182
+ SEGMENTS+=("$current")
183
+ return 0
184
+ }
185
+
186
+ shell_words() {
187
+ local text="$1"
188
+ local word="" i len ch quote="" escaped=0 in_word=0
189
+ WORDS=()
190
+ len=${#text}
191
+ for ((i = 0; i < len; i++)); do
192
+ ch="${text:i:1}"
193
+ if (( escaped )); then
194
+ word+="$ch"
195
+ in_word=1
196
+ escaped=0
197
+ continue
198
+ fi
199
+ if [ -n "$quote" ]; then
200
+ if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
201
+ escaped=1
202
+ continue
203
+ fi
204
+ if [ "$ch" = "$quote" ]; then
205
+ quote=""
206
+ else
207
+ word+="$ch"
208
+ fi
209
+ in_word=1
210
+ continue
211
+ fi
212
+
213
+ case "$ch" in
214
+ [[:space:]])
215
+ if (( in_word )); then
216
+ WORDS+=("$word")
217
+ word=""
218
+ in_word=0
219
+ fi
220
+ ;;
221
+ "'"|'"')
222
+ quote="$ch"
223
+ in_word=1
224
+ ;;
225
+ "\\")
226
+ escaped=1
227
+ in_word=1
228
+ ;;
229
+ *)
230
+ word+="$ch"
231
+ in_word=1
232
+ ;;
233
+ esac
234
+ done
235
+
236
+ if [ -n "$quote" ] || (( escaped )); then
237
+ return 1
238
+ fi
239
+ if (( in_word )); then
240
+ WORDS+=("$word")
241
+ fi
242
+ return 0
243
+ }
244
+
245
+ validate_git_branch() {
246
+ local i="$1" arg
247
+ while (( i < ${#WORDS[@]} )); do
248
+ arg="${WORDS[i]}"
249
+ case "$arg" in
250
+ --list|--all|--remotes|--show-current|-a|-r|-v|-vv)
251
+ ;;
252
+ *)
253
+ deny_segment "Read-only Bash guard denied a git branch mutation or unsupported branch option."
254
+ return 1
255
+ ;;
256
+ esac
257
+ ((i += 1))
258
+ done
259
+ return 0
260
+ }
261
+
262
+ validate_git_read_args() {
263
+ local i="$1" arg
264
+ while (( i < ${#WORDS[@]} )); do
265
+ arg="${WORDS[i]}"
266
+ if [ "$arg" = "--" ]; then
267
+ return 0
268
+ fi
269
+ case "$arg" in
270
+ --output|--output=*)
271
+ deny_segment "Read-only Bash guard denied git output-to-file options."
272
+ return 1
273
+ ;;
274
+ esac
275
+ ((i += 1))
276
+ done
277
+ return 0
278
+ }
279
+
280
+ validate_git() {
281
+ local i="$1" arg sub
282
+ while (( i < ${#WORDS[@]} )); do
283
+ arg="${WORDS[i]}"
284
+ case "$arg" in
285
+ -C)
286
+ ((i += 1))
287
+ if (( i >= ${#WORDS[@]} )); then
288
+ deny_segment "Read-only Bash guard denied git -C without a path."
289
+ return 1
290
+ fi
291
+ ((i += 1))
292
+ ;;
293
+ --no-pager)
294
+ ((i += 1))
295
+ ;;
296
+ -c|--config-env|--config-env=*)
297
+ deny_segment "Read-only Bash guard denied git configuration or alias injection."
298
+ return 1
299
+ ;;
300
+ --)
301
+ ((i += 1))
302
+ break
303
+ ;;
304
+ -*)
305
+ deny_segment "Read-only Bash guard denied an unsupported git global option."
306
+ return 1
307
+ ;;
308
+ *)
309
+ break
310
+ ;;
311
+ esac
312
+ done
313
+
314
+ if (( i >= ${#WORDS[@]} )); then
315
+ deny_segment "Read-only Bash guard denied git without an explicit read-only subcommand."
316
+ return 1
317
+ fi
318
+
319
+ sub="${WORDS[i],,}"
320
+ ((i += 1))
321
+ case "$sub" in
322
+ diff|log|status|show|blame|grep|rev-parse|describe)
323
+ validate_git_read_args "$i"
324
+ return $?
325
+ ;;
326
+ branch)
327
+ validate_git_branch "$i"
328
+ return $?
329
+ ;;
330
+ *)
331
+ deny_segment "Read-only Bash guard denied a git subcommand that is not in the read-only allow-list."
332
+ return 1
333
+ ;;
334
+ esac
335
+ }
336
+
337
+ find_arg_takes_operand() {
338
+ local arg="$1"
339
+ case "$arg" in
340
+ -name|-iname|-path|-ipath|-wholename|-iwholename|-regex|-iregex|-type|-xtype|-user|-group|-newer|-samefile|-size|-perm|-mtime|-mmin|-atime|-amin|-ctime|-cmin|-maxdepth|-mindepth|-printf|-printf0)
341
+ return 0
342
+ ;;
343
+ -newer??)
344
+ return 0
345
+ ;;
346
+ *)
347
+ return 1
348
+ ;;
349
+ esac
350
+ }
351
+
352
+ validate_find() {
353
+ local i="$1" arg skip_next=0
354
+ while (( i < ${#WORDS[@]} )); do
355
+ arg="${WORDS[i]}"
356
+ if (( skip_next )); then
357
+ skip_next=0
358
+ ((i += 1))
359
+ continue
360
+ fi
361
+ case "$arg" in
362
+ -exec|-execdir|-ok|-okdir|-delete|-fls|-fprint|-fprintf|-fprint0)
363
+ deny_segment "Read-only Bash guard denied find mutating action predicates."
364
+ return 1
365
+ ;;
366
+ esac
367
+ if find_arg_takes_operand "$arg"; then
368
+ skip_next=1
369
+ fi
370
+ ((i += 1))
371
+ done
372
+ return 0
373
+ }
374
+
375
+ validate_fd() {
376
+ local i="$1" arg
377
+ while (( i < ${#WORDS[@]} )); do
378
+ arg="${WORDS[i]}"
379
+ if [ "$arg" = "--" ]; then
380
+ return 0
381
+ fi
382
+ case "$arg" in
383
+ -x|-X|--exec|--exec-batch)
384
+ deny_segment "Read-only Bash guard denied fd exec action options."
385
+ return 1
386
+ ;;
387
+ esac
388
+ ((i += 1))
389
+ done
390
+ return 0
391
+ }
392
+
393
+ validate_rg() {
394
+ local i="$1" arg
395
+ while (( i < ${#WORDS[@]} )); do
396
+ arg="${WORDS[i]}"
397
+ if [ "$arg" = "--" ]; then
398
+ return 0
399
+ fi
400
+ case "$arg" in
401
+ --pre|--pre=*)
402
+ deny_segment "Read-only Bash guard denied rg preprocessor execution options."
403
+ return 1
404
+ ;;
405
+ esac
406
+ ((i += 1))
407
+ done
408
+ return 0
409
+ }
410
+
411
+ validate_sort() {
412
+ local i="$1" arg
413
+ while (( i < ${#WORDS[@]} )); do
414
+ arg="${WORDS[i]}"
415
+ if [ "$arg" = "--" ]; then
416
+ return 0
417
+ fi
418
+ case "$arg" in
419
+ -o|-o?*|--output|--output=*)
420
+ deny_segment "Read-only Bash guard denied sort output-to-file options."
421
+ return 1
422
+ ;;
423
+ esac
424
+ ((i += 1))
425
+ done
426
+ return 0
427
+ }
428
+
429
+ uniq_arg_takes_operand() {
430
+ local arg="$1"
431
+ case "$arg" in
432
+ -f|-s|-w|--skip-fields|--skip-chars|--check-chars)
433
+ return 0
434
+ ;;
435
+ *)
436
+ return 1
437
+ ;;
438
+ esac
439
+ }
440
+
441
+ validate_uniq() {
442
+ local i="$1" arg operands=0 after_options=0 skip_next=0
443
+ while (( i < ${#WORDS[@]} )); do
444
+ arg="${WORDS[i]}"
445
+ if (( skip_next )); then
446
+ skip_next=0
447
+ ((i += 1))
448
+ continue
449
+ fi
450
+ if (( ! after_options )) && [ "$arg" = "--" ]; then
451
+ after_options=1
452
+ ((i += 1))
453
+ continue
454
+ fi
455
+ if (( ! after_options )) && [ "$arg" != "-" ] && [[ "$arg" == -* ]]; then
456
+ if uniq_arg_takes_operand "$arg"; then
457
+ skip_next=1
458
+ fi
459
+ ((i += 1))
460
+ continue
461
+ fi
462
+
463
+ ((operands += 1))
464
+ if (( operands > 1 )); then
465
+ deny_segment "Read-only Bash guard denied uniq output-file operands."
466
+ return 1
467
+ fi
468
+ ((i += 1))
469
+ done
470
+ return 0
471
+ }
472
+
473
+ validate_yq() {
474
+ local i="$1" arg
475
+ while (( i < ${#WORDS[@]} )); do
476
+ arg="${WORDS[i]}"
477
+ if [ "$arg" = "--" ]; then
478
+ return 0
479
+ fi
480
+ case "$arg" in
481
+ -i|-i?*|--inplace|--inplace=*)
482
+ deny_segment "Read-only Bash guard denied yq in-place edit options."
483
+ return 1
484
+ ;;
485
+ esac
486
+ ((i += 1))
487
+ done
488
+ return 0
489
+ }
490
+
491
+ validate_bun() {
492
+ local i="$1" sub next
493
+ if (( i >= ${#WORDS[@]} )); then
494
+ deny_segment "Read-only Bash guard denied bun without an explicit test/typecheck command."
495
+ return 1
496
+ fi
497
+ sub="${WORDS[i],,}"
498
+ case "$sub" in
499
+ test)
500
+ return 0
501
+ ;;
502
+ run)
503
+ next="${WORDS[i+1]:-}"
504
+ if [ "$next" = "typecheck" ]; then
505
+ return 0
506
+ fi
507
+ deny_segment "Read-only Bash guard only allows bun run typecheck in read-only sessions."
508
+ return 1
509
+ ;;
510
+ *)
511
+ deny_segment "Read-only Bash guard denied a bun command outside the test/typecheck allow-list."
512
+ return 1
513
+ ;;
514
+ esac
515
+ }
516
+
517
+ validate_agent_relay() {
518
+ local i="$1" sub next
519
+ if (( i >= ${#WORDS[@]} )); then
520
+ deny_segment "Read-only Bash guard denied agent-relay without an allowed read/message subcommand."
521
+ return 1
522
+ fi
523
+ sub="${WORDS[i]}"
524
+ next="${WORDS[i+1]:-}"
525
+ case "$sub" in
526
+ /reply|/react|/message|/status|/read-message|/tags|/whoami|/guide)
527
+ return 0
528
+ ;;
529
+ get-message|status|whoami|guide|tags)
530
+ return 0
531
+ ;;
532
+ workspace)
533
+ if [ "$next" = "status" ]; then
534
+ return 0
535
+ fi
536
+ deny_segment "Read-only Bash guard denied a mutating agent-relay workspace command."
537
+ return 1
538
+ ;;
539
+ *)
540
+ deny_segment "Read-only Bash guard denied an agent-relay command outside the read/message allow-list."
541
+ return 1
542
+ ;;
543
+ esac
544
+ }
545
+
546
+ validate_leading_assignment() {
547
+ local assignment="$1" name value
548
+ name="${assignment%%=*}"
549
+ value="${assignment#*=}"
550
+
551
+ case "$name" in
552
+ LANG|LANGUAGE|LC_*)
553
+ if [[ "$value" =~ ^[A-Za-z0-9_.:@+-]+$ ]]; then
554
+ return 0
555
+ fi
556
+ ;;
557
+ TZ)
558
+ if [[ "$value" =~ ^[A-Za-z0-9_+:-]+(/[A-Za-z0-9_+:-]+)*$ ]]; then
559
+ return 0
560
+ fi
561
+ ;;
562
+ PAGER|GIT_PAGER)
563
+ if [ "$value" = "cat" ]; then
564
+ return 0
565
+ fi
566
+ ;;
567
+ esac
568
+
569
+ deny_segment "Read-only Bash guard denied unsafe leading environment assignment."
570
+ return 1
571
+ }
572
+
573
+ validate_segment() {
574
+ local raw="$1" segment i=0 saw_assignment=0 command_name base
575
+ segment="$(trim_shell_text "$raw")"
576
+ if [ -z "$segment" ]; then
577
+ return 0
578
+ fi
579
+ if ! shell_words "$segment"; then
580
+ deny_segment "Read-only Bash guard denied shell syntax it could not parse."
581
+ return 1
582
+ fi
583
+ if (( ${#WORDS[@]} == 0 )); then
584
+ return 0
585
+ fi
586
+
587
+ while (( i < ${#WORDS[@]} )) && [[ "${WORDS[i]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do
588
+ if ! validate_leading_assignment "${WORDS[i]}"; then
589
+ return 1
590
+ fi
591
+ saw_assignment=1
592
+ ((i += 1))
593
+ done
594
+
595
+ if (( i >= ${#WORDS[@]} )); then
596
+ deny_segment "Read-only Bash guard denied an assignment-only shell segment."
597
+ return 1
598
+ fi
599
+
600
+ command_name="${WORDS[i]}"
601
+ if [[ "$command_name" == \$* ]]; then
602
+ deny_segment "Read-only Bash guard denied an indirect command name."
603
+ return 1
604
+ fi
605
+
606
+ base="${command_name##*/}"
607
+ base="${base,,}"
608
+ if [ -z "$base" ]; then
609
+ deny_segment "Read-only Bash guard denied an empty command name."
610
+ return 1
611
+ fi
612
+
613
+ case "$base" in
614
+ bash|sh|zsh|dash|env|python*|node|ruby|perl|php|awk|eval|exec|source|.)
615
+ deny_segment "Read-only Bash guard denied a nested shell, interpreter, or shell re-entry command."
616
+ return 1
617
+ ;;
618
+ esac
619
+
620
+ if (( saw_assignment )) && [[ "$command_name" == *'$'* ]]; then
621
+ deny_segment "Read-only Bash guard denied assignment-based command indirection."
622
+ return 1
623
+ fi
624
+
625
+ case "$base" in
626
+ git)
627
+ validate_git "$((i + 1))"
628
+ return $?
629
+ ;;
630
+ find)
631
+ validate_find "$((i + 1))"
632
+ return $?
633
+ ;;
634
+ fd)
635
+ validate_fd "$((i + 1))"
636
+ return $?
637
+ ;;
638
+ rg)
639
+ validate_rg "$((i + 1))"
640
+ return $?
641
+ ;;
642
+ sort)
643
+ validate_sort "$((i + 1))"
644
+ return $?
645
+ ;;
646
+ uniq)
647
+ validate_uniq "$((i + 1))"
648
+ return $?
649
+ ;;
650
+ yq)
651
+ validate_yq "$((i + 1))"
652
+ return $?
653
+ ;;
654
+ grep|cat|ls|head|tail|wc|pwd|jq|nl|cut)
655
+ return 0
656
+ ;;
657
+ bun)
658
+ validate_bun "$((i + 1))"
659
+ return $?
660
+ ;;
661
+ agent-relay)
662
+ validate_agent_relay "$((i + 1))"
663
+ return $?
664
+ ;;
665
+ *)
666
+ deny_segment "Read-only Bash guard denied a command outside the read-only allow-list."
667
+ return 1
668
+ ;;
669
+ esac
670
+ }
671
+
672
+ if reason="$(hard_deny_reason "$command_text")"; then
673
+ emit_deny "$reason"
674
+ exit 0
675
+ fi
676
+
677
+ if ! split_segments "$command_text"; then
678
+ emit_deny "Read-only Bash guard denied shell syntax it could not parse."
679
+ exit 0
680
+ fi
681
+
682
+ for segment in "${SEGMENTS[@]}"; do
683
+ if ! validate_segment "$segment"; then
684
+ emit_deny "${DENY_REASON:-Read-only Claude sessions may only run explicit inspection commands.}"
685
+ exit 0
686
+ fi
687
+ done
688
+
689
+ exit 0