Skip to content

Commit f04edf8

Browse files
kyle-sextonclaude
andcommitted
fix(guardrails): cap launcher, child-shell and eval nesting in the root-delete guard
rdt_check_segment recursed with no depth bound through the runuser -u arm, rdt_su_shell_run and eval. At 120 nested runusers the block message printed but the process exited 0: the recursion had left so little stack that rdt_block died inside its telemetry emission (hook::_json_split, reached through rdt_emit_tel with HOOK_TELEMETRY_SINK set) before reaching exit 2, and deeper nesting died on SIGSEGV. A dynamic-local depth counter now refuses past 24 levels, restored on every return by scope, and nested eval text is charged to the tokenizing budget so eval chains refuse in seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 577eb23 commit f04edf8

3 files changed

Lines changed: 65 additions & 1 deletion

File tree

‎plugins/guardrails/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ All notable changes to the `guardrails` plugin are documented here. Format follo
1212
- **su parses every `-c`-like operand, not only the first.** `su bob -c true -c 'rm -rf /'` returned 0, because su runs the last `-c` and the guard read the first. su's `--command` and `--session-command` also match on any unambiguous prefix now, and an operand attached to `-c` (`su -c'rm -rf /'`, one word `-crm -rf /`) is parsed too.
1313
- **`--` no longer hides a launcher's positional.** For every launcher that takes a positional ahead of the command (`taskset`, `flock`, `chroot`, `chrt`, `timeout`), the word after `--` is still that positional, so `timeout -- 5 rm -rf /` is refused; chrt's priority must be all digits and timeout's duration must start like a number, so `timeout -- rm -rf /` stays refused.
1414
- **Launcher long options match on any unambiguous prefix** (`flock --wa 5`, `nsenter --ta 1`, `timeout --k 1`), except sudo's, and a short cluster ending in an operand-taking letter takes the next word as getopt reads it (`flock -nw 1`, `chrt -dT 1000`); that reading is judged beside the plain one in every segment, so `nice --adj rm -rf /` stays refused, and past 256 such readings in one command the guard refuses rather than judging one reading only. A `-s` / `--shell` naming a program that is not a shell is judged as the command (`su root -s /bin/rm -- -rf /`), `-c -- '…'` is read as `-c '…'`, a `-c` operand of exactly `--` hands the next word to the shell (`su root --com=-- 'rm -rf /'`), a word runuser rejects is never its command word, flock's `-c` after `--` and its lock file is parsed, and timeout's duration after `--` is read in strtod's shape (`+5`, `' 5'`, `inf`).
15+
- **Launcher, child-shell and eval nesting is capped at 24 levels, and past it the guard refuses.** Each level re-enters the parser, and 120 nested `runuser -u bob --` exhausted bash's stack inside the block's telemetry, so the BLOCKED message printed and the process exited 0; deeper nesting died on SIGSEGV. Nested evals are also charged to the tokenizing budget, so `eval` repeated hundreds of times refuses in seconds instead of outrunning the hook timeout.
1516
- **Still declared gaps, all in sudo:** `sudo -R` / `--chroot`, a short cluster ending in an operand-taking letter (`sudo -Eu bob`), and an abbreviated long option (`sudo --us bob`). Reading them correctly changes how sudo lines refused today are read (`sudo -R rm -rf /` would take `rm` as the chroot directory), and this guard only adds refusals.
1617

1718
## [0.36.5] - 2026-09-24

‎plugins/guardrails/hooks/block-root-delete-target.sh‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ MAX_COMMAND_LEN=16384
188188
# allow on exactly the input built to exhaust it.
189189
MAX_SUBST_DEPTH=32
190190

191+
# Launcher, child-shell and eval nesting is recursion in rdt_check_segment,
192+
# capped the same way and for the same reason: past the cap the guard REFUSES.
193+
# 24 is far above any command a person writes and far below the depth where
194+
# bash exhausts its stack (about 100 levels of runuser on Git Bash).
195+
MAX_SEGMENT_DEPTH=24
196+
rdt_depth=0
197+
191198
rdt_emit_tel() {
192199
[[ -n "$start" ]] || return 0
193200
hook::telemetry_enabled || return 0
@@ -222,6 +229,18 @@ rdt_block() {
222229
'Past that depth the scanner stops descending, so a recursive delete inside it cannot be ruled out, and an allow here would be an allow on exactly the input built to exhaust it.' \
223230
'Fix: flatten the command substitutions, or assign the inner results to variables in separate commands.' >&2
224231
;;
232+
eval-too-long)
233+
printf '%s\n' \
234+
'BLOCKED: eval and substitution text exceeds MAX_COMMAND_LEN in total.' \
235+
'Each eval re-tokenizes the text it runs, so nested evals multiply the work, and a hook the harness cancels on its timeout is cancelled WITHOUT a block.' \
236+
'Fix: drop the nested evals, or run the inner command on its own.' >&2
237+
;;
238+
nesting-too-deep-launcher)
239+
printf '%s\n' \
240+
"BLOCKED: launcher or eval nesting deeper than $MAX_SEGMENT_DEPTH; flatten the command." \
241+
'Each launcher, child shell and eval is judged by re-entering the parser, and past the limit a recursive delete inside it cannot be ruled out.' \
242+
'Fix: drop the repeated launchers, or run the inner command on its own.' >&2
243+
;;
225244
too-many-abbreviations)
226245
printf '%s\n' \
227246
'BLOCKED: too many command segments with abbreviated launcher options to judge every reading; spell the options in full.' \
@@ -638,6 +657,13 @@ rdt_resolved_walk() {
638657
# siblings already define a function named check_segment.
639658
# shellcheck disable=SC2329 # invoked indirectly as the hook::bash_parse_segments callback
640659
rdt_check_segment() {
660+
# Every launcher, child shell and eval re-enters this function, so nesting
661+
# is bash recursion; deep enough, bash exhausts its stack and dies before a
662+
# block's `exit 2`. The depth is a dynamic local, so every return restores
663+
# the caller's count with no bookkeeping, and past MAX_SEGMENT_DEPTH the
664+
# guard REFUSES.
665+
local rdt_depth=$((rdt_depth + 1))
666+
((rdt_depth > MAX_SEGMENT_DEPTH)) && rdt_block "nesting-too-deep-launcher"
641667
local -a words=("$@")
642668
local n=$# i=0 j w base sval optarg consume_bare
643669
local abbr_forked=0
@@ -981,7 +1007,13 @@ rdt_check_segment() {
9811007
ev+=("${words[j]}")
9821008
fi
9831009
done
984-
hook::bash_parse_segments "${ev[*]}" rdt_check_segment
1010+
# The joined text is charged to the same tokenizing budget as a
1011+
# substitution body: nested evals re-tokenize nearly the whole command at
1012+
# every level, which is the same multiplication the budget exists to stop.
1013+
local evtext="${ev[*]}"
1014+
rdt_scanned=$((rdt_scanned + ${#evtext}))
1015+
((rdt_scanned > MAX_COMMAND_LEN)) && rdt_block "eval-too-long"
1016+
hook::bash_parse_segments "$evtext" rdt_check_segment
9851017
return 0
9861018
fi
9871019

‎plugins/guardrails/hooks/block-root-delete-target.test.sh‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,37 @@ assert_exit "1300 sibling rm-bearing bodies are refused" 2 "$GUARD_RC"
288288
assert_contains "1300 siblings are refused by the COMMAND ceiling, not the body budget" \
289289
"$GUARD_ERR" "the command is too long to parse"
290290

291+
# Launcher, child-shell and eval nesting is recursion in the guard. Unbounded,
292+
# 120 nested runusers exhausted bash's stack inside the block's telemetry, so
293+
# the BLOCKED message printed and the process still exited 0, and deeper ones
294+
# died on SIGSEGV. Past MAX_SEGMENT_DEPTH the guard refuses, and nested evals
295+
# are charged to the tokenizing budget so they refuse fast rather than
296+
# outrunning the hook timeout.
297+
rdt_rep() {
298+
local s="" k
299+
for ((k = 0; k < $2; k++)); do s+="$1"; done
300+
printf '%s' "$s"
301+
}
302+
expect_both 'runuser -u nested 120 deep is refused' 2 --command "$(rdt_rep 'runuser -u bob -- ' 120)rm -rf /"
303+
expect_both 'runuser -u nested 900 deep is refused' 2 --command "$(rdt_rep 'runuser -u bob -- ' 900)rm -rf /"
304+
expect_both 'su -s /bin/su nested 300 deep is refused' 2 \
305+
--command "$(rdt_rep 'su root -s /bin/su -- ' 300)root -s /bin/rm -- -rf /"
306+
rdt_payload="$(command_json "$(rdt_rep 'flock --wa 1 f eval ' 700)rm -rf /")"
307+
for rdt_via in direct dispatched; do
308+
if [[ "$rdt_via" == direct ]]; then
309+
rdt_argv=(bash "$HOOK")
310+
else
311+
rdt_argv=(bash "$GUARD_DISPATCH" "$HOOK")
312+
fi
313+
rdt_rc=0
314+
timeout 20 "${rdt_argv[@]}" <<<"$rdt_payload" >/dev/null 2>&1 || rdt_rc=$?
315+
assert_exit "flock/eval nested 700 deep is refused inside the timeout ($rdt_via)" 2 "$rdt_rc"
316+
done
317+
expect_both 'moderate launcher nesting with an ordinary delete allowed' 0 \
318+
--command "sudo nice timeout 5 runuser -u bob -- bash -c 'rm -rf ./build'"
319+
expect_both 'moderate launcher nesting with a root delete blocks' 2 \
320+
--command "sudo nice timeout 5 runuser -u bob -- bash -c 'rm -rf /'"
321+
291322
expect_both 'substitution nested 3 deep is still parsed' 2 \
292323
--command 'echo "$(echo "$(echo "$(rm -rf /)")")"'
293324

0 commit comments

Comments
 (0)