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.
- package/package.json +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/hooks/hooks.json +10 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +689 -0
- package/src/adapters/claude-transcript.ts +44 -0
- package/src/adapters/claude.ts +9 -9
- package/src/busy-reconciler.ts +84 -0
- package/src/control-server.ts +11 -1
- package/src/mcp-outbox.ts +86 -0
- package/src/rate-limit.ts +2 -2
- package/src/runner-core.ts +2224 -0
- package/src/runner-helpers.ts +317 -0
- package/src/runner-insights.ts +106 -0
- package/src/runner.ts +14 -2612
|
@@ -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
|