agent-relay-runner 0.62.2 → 0.62.3
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/control-server.ts +11 -1
- package/src/runner.ts +52 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.62.
|
|
3
|
+
"version": "0.62.3",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-sdk": "0.2.40"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
|
@@ -12,6 +12,16 @@
|
|
|
12
12
|
}
|
|
13
13
|
],
|
|
14
14
|
"PreToolUse": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "Bash",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/read-only-bash-guard.sh\"",
|
|
21
|
+
"timeout": 5
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
15
25
|
{
|
|
16
26
|
"matcher": "AskUserQuestion",
|
|
17
27
|
"hooks": [
|
|
@@ -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
|
|
@@ -126,6 +126,50 @@ export function extractLastAssistantTurn(jsonl: string): string {
|
|
|
126
126
|
return collected.join("\n\n").trim();
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Like extractLastAssistantTurn but skips the first `afterEntry` valid JSON entries.
|
|
131
|
+
* Used when a partial transcript flush already emitted entries 1..afterEntry so the
|
|
132
|
+
* Stop-hook full-mode capture doesn't re-emit the already-published text.
|
|
133
|
+
*/
|
|
134
|
+
export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: number): string {
|
|
135
|
+
const lines = jsonl.split("\n");
|
|
136
|
+
const collected: string[] = [];
|
|
137
|
+
let entryIndex = 0;
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
const trimmed = line.trim();
|
|
140
|
+
if (!trimmed) continue;
|
|
141
|
+
let entry: TranscriptEntry;
|
|
142
|
+
try {
|
|
143
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
entryIndex++;
|
|
148
|
+
if (isSidechainEntry(entry)) continue;
|
|
149
|
+
if (isRealUserPrompt(entry)) {
|
|
150
|
+
collected.length = 0;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (entryIndex <= afterEntry) continue;
|
|
154
|
+
const text = assistantText(entry);
|
|
155
|
+
if (text) collected.push(text);
|
|
156
|
+
}
|
|
157
|
+
return collected.join("\n\n").trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Count valid JSON entries in a JSONL string. Used to record the high-water-mark
|
|
162
|
+
* after a pre-flush so the Stop hook knows where to resume in full-capture mode.
|
|
163
|
+
*/
|
|
164
|
+
export function countTranscriptEntries(jsonl: string): number {
|
|
165
|
+
let count = 0;
|
|
166
|
+
for (const line of jsonl.split("\n")) {
|
|
167
|
+
if (!line.trim()) continue;
|
|
168
|
+
try { JSON.parse(line); count++; } catch {}
|
|
169
|
+
}
|
|
170
|
+
return count;
|
|
171
|
+
}
|
|
172
|
+
|
|
129
173
|
/**
|
|
130
174
|
* Returns only the text from the LAST assistant entry in the current turn.
|
|
131
175
|
* Unlike extractLastAssistantTurn which collects all intermediate text between
|
package/src/adapters/claude.ts
CHANGED
|
@@ -450,25 +450,21 @@ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[]
|
|
|
450
450
|
}
|
|
451
451
|
|
|
452
452
|
export function claudeLaunchArgs(defaultArgs: string[], providerArgs: string[], approvalMode?: string): string[] {
|
|
453
|
+
const stripToolArgs = approvalMode === "read-only";
|
|
453
454
|
return [
|
|
454
|
-
...stripClaudePermissionArgs(defaultArgs),
|
|
455
|
-
...stripClaudePermissionArgs(providerArgs),
|
|
455
|
+
...stripClaudePermissionArgs(defaultArgs, stripToolArgs),
|
|
456
|
+
...stripClaudePermissionArgs(providerArgs, stripToolArgs),
|
|
456
457
|
...claudePermissionModeArgs(approvalMode),
|
|
457
458
|
];
|
|
458
459
|
}
|
|
459
460
|
|
|
460
461
|
export function claudePermissionModeArgs(approvalMode?: string): string[] {
|
|
461
462
|
if (approvalMode === "open") return ["--dangerously-skip-permissions"];
|
|
462
|
-
if (approvalMode === "read-only") return [
|
|
463
|
-
"--permission-mode",
|
|
464
|
-
"dontAsk",
|
|
465
|
-
"--allowedTools",
|
|
466
|
-
"Read,Grep,Glob,LS,Bash(agent-relay *)",
|
|
467
|
-
];
|
|
463
|
+
if (approvalMode === "read-only") return ["--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob,LS,Bash"];
|
|
468
464
|
return ["--permission-mode", "default"];
|
|
469
465
|
}
|
|
470
466
|
|
|
471
|
-
function stripClaudePermissionArgs(args: string[]): string[] {
|
|
467
|
+
function stripClaudePermissionArgs(args: string[], stripToolArgs = false): string[] {
|
|
472
468
|
const result: string[] = [];
|
|
473
469
|
for (let i = 0; i < args.length; i++) {
|
|
474
470
|
const arg = args[i];
|
|
@@ -484,6 +480,10 @@ function stripClaudePermissionArgs(args: string[]): string[] {
|
|
|
484
480
|
i++;
|
|
485
481
|
continue;
|
|
486
482
|
}
|
|
483
|
+
if (stripToolArgs && /^--(?:tools|allowedTools|allowed-tools|disallowedTools|disallowed-tools)(?:=|$)/.test(arg)) {
|
|
484
|
+
if (!arg.includes("=")) i++;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
487
|
result.push(arg);
|
|
488
488
|
}
|
|
489
489
|
return result;
|
package/src/control-server.ts
CHANGED
|
@@ -36,7 +36,9 @@ interface ControlServerOptions {
|
|
|
36
36
|
// Phase 1 live-session lane: a provider Stop hook hands over its transcript
|
|
37
37
|
// path so the runner can capture the assistant turn and surface it in the
|
|
38
38
|
// dashboard chat without the agent re-emitting it via /reply.
|
|
39
|
-
|
|
39
|
+
// isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
|
|
40
|
+
// un-mirrored narrative before the interactive form appears in chat (#435).
|
|
41
|
+
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void>;
|
|
40
42
|
// A provider UserPromptSubmit hook hands over the prompt the human typed
|
|
41
43
|
// directly into the session (web terminal / TUI) so the runner can mirror it
|
|
42
44
|
// into the dashboard chat and start tailing the turn transcript for reasoning.
|
|
@@ -221,6 +223,14 @@ async function handlePermissionRequest(
|
|
|
221
223
|
): Promise<Response> {
|
|
222
224
|
const body = await req.json().catch(() => null);
|
|
223
225
|
if (!isRecord(body)) return Response.json({});
|
|
226
|
+
// #435: flush any un-mirrored narrative from the transcript before the form
|
|
227
|
+
// appears in chat — the transcript already has the assistant text block at
|
|
228
|
+
// PreToolUse time, but the Stop hook (which normally captures it) won't fire
|
|
229
|
+
// until after the user answers, so the form would otherwise arrive first.
|
|
230
|
+
const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
|
|
231
|
+
if (transcriptPath && options.onSessionTurn) {
|
|
232
|
+
await options.onSessionTurn({ transcriptPath, isPreFlush: true }).catch(() => {});
|
|
233
|
+
}
|
|
224
234
|
const approvalId = crypto.randomUUID();
|
|
225
235
|
const view = claudePermissionApprovalView(approvalId, body);
|
|
226
236
|
options.onStatus({
|
package/src/runner.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { ClaimTracker } from "./claim-tracker";
|
|
|
13
13
|
import { startControlServer, type ControlServer } from "./control-server";
|
|
14
14
|
import { ReplyObligationCache, obligationRequiresExplicitReply } from "./reply-obligation-cache";
|
|
15
15
|
import { Outbox, type OutboxRecord } from "./outbox";
|
|
16
|
-
import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
|
|
16
|
+
import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
|
|
17
17
|
import { computeContextRatio } from "./session-insights";
|
|
18
18
|
import { agentProfileProjectionReport } from "./profile-projection";
|
|
19
19
|
import { profileUsesHostProviderGlobals } from "./profile-home";
|
|
@@ -254,6 +254,10 @@ export class AgentRunner {
|
|
|
254
254
|
// its final response. Set when a provider-turn starts, cleared when it ends.
|
|
255
255
|
private currentTurnId?: string;
|
|
256
256
|
private currentTurnStartedAt?: number;
|
|
257
|
+
// #435: high-water-mark for the pre-turn narrative flush. Counts how many
|
|
258
|
+
// valid JSONL entries were in the transcript at last pre-flush time so the
|
|
259
|
+
// Stop-hook capture (full mode) can skip what was already emitted.
|
|
260
|
+
private narrativeFlushEntryCount = 0;
|
|
257
261
|
// True while a turn that was already in flight is being compacted. Claude's PostCompact
|
|
258
262
|
// hook posts a single `idle`, but a mid-turn compaction RESUMES the turn afterward — so
|
|
259
263
|
// treating that idle as a turn end (flip idle, kill the reasoning tail) makes chat go dark
|
|
@@ -1283,8 +1287,33 @@ export class AgentRunner {
|
|
|
1283
1287
|
// relay message existing, so turns started from the web terminal (which create
|
|
1284
1288
|
// no relay message) are mirrored too. A reply obligation, when present, is still
|
|
1285
1289
|
// used as replyTo so the Stop hook stops nagging the agent to /reply.
|
|
1286
|
-
|
|
1290
|
+
//
|
|
1291
|
+
// isPreFlush: true (#435) — mid-turn call from a PreToolUse hook (AskUserQuestion /
|
|
1292
|
+
// ExitPlanMode / permission). Emits any un-mirrored narrative from the transcript
|
|
1293
|
+
// tail immediately, before the interactive form appears in chat, then updates the
|
|
1294
|
+
// entry-count cursor so the eventual Stop-hook call (full mode) skips it.
|
|
1295
|
+
private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void> {
|
|
1287
1296
|
if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
|
|
1297
|
+
|
|
1298
|
+
if (input.isPreFlush) {
|
|
1299
|
+
if (!input.transcriptPath) return;
|
|
1300
|
+
let jsonl: string | undefined;
|
|
1301
|
+
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
|
|
1302
|
+
if (!jsonl) return;
|
|
1303
|
+
const body = extractLastAssistantTurnAfterEntry(jsonl, this.narrativeFlushEntryCount);
|
|
1304
|
+
this.narrativeFlushEntryCount = countTranscriptEntries(jsonl);
|
|
1305
|
+
if (!body) return;
|
|
1306
|
+
const turnId = this.currentTurnId;
|
|
1307
|
+
this.sessionLog(`pre-flush narrative for turn ${turnId ?? "?"} (${body.length} chars)`);
|
|
1308
|
+
await this.publishSessionEvent({
|
|
1309
|
+
from: this.agentId,
|
|
1310
|
+
to: "user",
|
|
1311
|
+
body,
|
|
1312
|
+
session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) },
|
|
1313
|
+
});
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1288
1317
|
const turnId = this.currentTurnId;
|
|
1289
1318
|
this.stopReasoningTail();
|
|
1290
1319
|
// Optional correlation for threading + obligation clearing — never a capture gate.
|
|
@@ -1300,6 +1329,12 @@ export class AgentRunner {
|
|
|
1300
1329
|
replyToMessageId = obligation?.messageId;
|
|
1301
1330
|
}
|
|
1302
1331
|
|
|
1332
|
+
// Grab and reset the pre-flush cursor before extraction: the cursor may be
|
|
1333
|
+
// non-zero when a PreToolUse hook flushed narrative earlier this turn (#435).
|
|
1334
|
+
// In "full" mode, skip those already-emitted entries so they don't duplicate.
|
|
1335
|
+
const flushCount = this.narrativeFlushEntryCount;
|
|
1336
|
+
this.narrativeFlushEntryCount = 0;
|
|
1337
|
+
|
|
1303
1338
|
// The Stop hook can fire before the final assistant entry is flushed to disk.
|
|
1304
1339
|
// Claude writes thinking and text as separate entries (both with end_turn), so
|
|
1305
1340
|
// the transcript can "look complete" while the text entry is still pending.
|
|
@@ -1314,7 +1349,11 @@ export class AgentRunner {
|
|
|
1314
1349
|
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
|
|
1315
1350
|
}
|
|
1316
1351
|
if (!transcriptLooksComplete(jsonl)) continue;
|
|
1317
|
-
|
|
1352
|
+
// Full mode: if a pre-flush already emitted entries 1..flushCount, only
|
|
1353
|
+
// collect text from entries after that mark to avoid double-emit (#435).
|
|
1354
|
+
const extract = this.options.providerConfig.chatCaptureMode === "full"
|
|
1355
|
+
? (j: string) => (flushCount > 0 ? extractLastAssistantTurnAfterEntry(j, flushCount) : extractLastAssistantTurn(j))
|
|
1356
|
+
: extractFinalAssistantMessage;
|
|
1318
1357
|
body = extract(jsonl);
|
|
1319
1358
|
if (body) break;
|
|
1320
1359
|
}
|
|
@@ -2466,6 +2505,7 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextState?: Cont
|
|
|
2466
2505
|
const model = options.model ?? probeModel?.model;
|
|
2467
2506
|
const effort = options.effort ?? probeModel?.effort;
|
|
2468
2507
|
const modelSource = options.model ? "runtime" as const : probeModel?.model ? "provider" as const : "runtime" as const;
|
|
2508
|
+
const shellMode = runtimeShellMode(options);
|
|
2469
2509
|
return {
|
|
2470
2510
|
lifecycle: {
|
|
2471
2511
|
managed: true,
|
|
@@ -2487,7 +2527,8 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextState?: Cont
|
|
|
2487
2527
|
approvalMode: options.approvalMode,
|
|
2488
2528
|
fileRead: true,
|
|
2489
2529
|
fileWrite: options.approvalMode !== "read-only",
|
|
2490
|
-
shell:
|
|
2530
|
+
shell: shellMode !== "none",
|
|
2531
|
+
shellMode,
|
|
2491
2532
|
source: "runtime",
|
|
2492
2533
|
confidence: "reported",
|
|
2493
2534
|
lastUpdatedAt: options.startedAt,
|
|
@@ -2511,6 +2552,13 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextState?: Cont
|
|
|
2511
2552
|
};
|
|
2512
2553
|
}
|
|
2513
2554
|
|
|
2555
|
+
function runtimeShellMode(options: RunnerOptions): NonNullable<NonNullable<ProviderCapabilities["session"]>["shellMode"]> {
|
|
2556
|
+
if (options.approvalMode === "read-only") {
|
|
2557
|
+
return options.provider === "claude" ? "read-only-guarded" : "none";
|
|
2558
|
+
}
|
|
2559
|
+
return options.approvalMode === "open" ? "unrestricted" : "guarded";
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2514
2562
|
function runtimeProviderTerminalCapabilities(options: RunnerOptions): Pick<ProviderCapabilities, "terminal"> {
|
|
2515
2563
|
if (options.provider === "claude" && options.headless) {
|
|
2516
2564
|
return {
|