feat(when): expose shell.loop_kind for matching commands inside shell loops#345
Conversation
…ll loops intent(when): let `when` clauses tell `until cmd; do sleep N; done` from a bare `sleep N`, so polling-loop wrappers can be denied while standalone sleeps stay allowed decision(parser): surface the loop kind as a single string `loop_kind` (`"while"`, `"until"`, `"for"`, or `""`) on every extracted sub-command and expose it via the existing flat CEL map shape (`shell.loop_kind`), mirroring `pipe.stdin` rather than introducing a `has()`-gated nullable rejected(parser): a richer struct with `in_loop` / `has_timeout_wrapper` / `in_background` / `subshell_depth` / `loop_stack` — out of scope for the MVP, and `in_loop` is redundant with `loop_kind != ""` learned(parser): tree-sitter-bash uses a single `while_statement` node for both `while` and `until`; the keyword shows up only as the leading anonymous token, so the kind has to be sniffed from the source bytes constraint(parser): substitutions discovered through `collect_substitutions_recursive` (e.g. `for i in "$(cmd)"`) intentionally reset to `loop_kind = ""` because the substitution runs once outside the loop body, matching the existing pipe/redirect-context reset
… path intent(adapter): make `shell.loop_kind` reach the entry path used by `for i in 1 2; do <single cmd>; done` (and any other loop whose body extracts to exactly one sub-command) — previously the single-command branch in the adapter called `evaluate_command_with_metadata` without threading `loop_kind`, so `when: shell.loop_kind == "for"` silently failed for the headline use case decision(parser): drop `command_substitution` from the `for_statement` body branch and route value-list substitutions through `collect_substitutions_recursive` so bare `$(cmd)` and quoted `"$(cmd)"` value-list substitutions both stamp `loop_kind = ""`, keeping behaviour consistent with bash semantics (the substitution runs once before the loop body) rather than diverging by quoting learned(parser): `collect_substitutions_recursive` only descends into a node's named children, so the `for_statement` arm has to keep an explicit `command_substitution` / `process_substitution` branch — the helper alone would silently miss a substitution that sits as a direct child of the for-statement
intent(docs): swap the `TODO(pr-link)` placeholder for the real PR link now that the PR has been opened
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #345 +/- ##
==========================================
- Coverage 89.07% 89.05% -0.02%
==========================================
Files 53 53
Lines 12657 12731 +74
==========================================
+ Hits 11274 11338 +64
- Misses 1383 1393 +10
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new shell.loop_kind CEL variable to detect shell loops (while, until, for) within when clauses. This feature allows users to distinguish between polling loops and standalone commands, enabling more granular rule enforcement, such as denying sleep inside a loop while permitting it elsewhere. The implementation includes updates to the command parser, rule engine, and expression evaluator to propagate and expose this context, supported by comprehensive documentation and new integration tests. The reviewer correctly identified that the TODO(pr-link) placeholder in the release notes must be resolved before merging.
Purpose
until cmd; do sleep N; doneshould be deniable from awhenclause, but runok currently exposes no way to tell that asleepruns inside a loop, so a rule cannot distinguish a pollingsleepfrom a standalone onegh pr checks --watch,kubectl wait, ...), the only rule that can block the polling form today is a blanket deny onsleep N, which also blocks the legitimate standalonesleepcalls that have to keep workingApproach
shell.loop_kind"while","until","for", or""when the command is not inside any loopsleepinfor x in a b; do until y; do sleep 1; done; donereports"until")sleepin(while x; do sleep 1; done)still reports"while")loop_kind = "", whether bare or quoted (for i in $(cmd); do ...; doneandfor i in "$(cmd)"; do ...; doneboth report""forcmd)Design decisions
CEL surface shape
shell.loop_kind: string, with""as the loop-outside sentinelpipe.stdinetc.;in [...]works withouthas()gating""means "outside any loop"shell.in_loop: boolfield with the stringin_loopreads naturally for the common caseloop_kind != ""; adds a field without enabling anything newloop_kindnullable and check it withhas(shell.loop_kind)pipe.*,redirects[].*) that already use sentinel values rather than nullabilityLoop kind for value-list substitutions
cmdinfor i in $(cmd); do ...; donereportsloop_kind = ""$(cmd)and quoted"$(cmd)"agree$(cmd)reportsloop_kind = "for", quoted"$(cmd)"reports""