diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index e7ba2c332..45509c765 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -204,7 +204,7 @@ text says, because the harm it covers was never in the text. rule builds is denied when it names both `sleep` and a loop keyword, since a wait needs both. A `for` loop in its arithmetic form, `for ((;;))`, is reached too, since it runs forever exactly as - `while true` does, while a `for x in ` is bounded by its own word list. The third is a loop whose condition is a + `while true` does, while a `for x in ` is bounded by its own word list. The third is a `while` loop whose condition is a `read` drawing on an input redirect that binds descriptor 0, on that loop's own invocation, which is bounded by that input, so throttling between iterations with a `sleep` is ordinary work rather than a leak. Four things have to hold, and a real command defeated each of them. @@ -320,7 +320,7 @@ flowchart TD isgit -- yes --> deny4["DENY - requirement 4\n(fails closed for a\nprotected-default branch\nwith undeterminable rules)"] isgit -- no --> isprimary{"A mutating git op\ntargeting a primary\ncheckout, not exempt?"} isprimary -- yes --> deny6["DENY - requirement 6"] - isprimary -- no --> iswait{"A while, until or\narithmetic-for loop\nthat sleeps, with no\narithmetic guard, no\nread of an input redirect,\nand (no timeout, or a\nfork out of the timeout's\nreach)?"} + isprimary -- no --> iswait{"A while, until or\narithmetic-for loop\nthat sleeps, with no\narithmetic guard, no\nwhile read of an input\nredirect,\nand (no timeout, or a\nfork out of the timeout's\nreach)?"} iswait -- yes --> deny7["DENY - requirement 7"] iswait -- no --> isghwrite{"A GitHub-write\ncommand at all?"} isghwrite -- no --> allow["ALLOW"] diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index dc530fcbe..cafff35e1 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -2418,8 +2418,8 @@ def _redirects_stdin(after_done): return bound -def _reads_its_input(cond, after_done): - """True if the loop's condition is a `read`, which ends the loop when the input is exhausted. +def _reads_its_input(keyword, cond, after_done): + """True if a `while` loop's condition is a `read`, which ends it when the input is exhausted. `while read -r line; do ...; sleep 1; done < file` is bounded by its input rather than by a clock, and throttling between iterations is the ordinary reason such a loop sleeps at all. @@ -2434,6 +2434,8 @@ def _reads_its_input(cond, after_done): `find | while read`, which is the safe direction, and the bound such a loop needs is the ordinary one. """ + if keyword != "while": + return False # A process substitution wears a redirect's clothes and is the same unknown producer a pipe is: # `done < <(yes)` and `done < <(tail -f log)` never exhaust, so neither reads as a bound. if any(t.startswith(("<(", ">(")) for t in after_done): @@ -2736,7 +2738,7 @@ def _unbounded_wait_loop(cmd, inherited_timeout=False, _depth=0): # Measured: the same leak as having written no bound at all. backgrounded = forks_away bounded = (inherited_timeout and not backgrounded) or _reads_its_input( - cond, toks[done_at + 1 :] + tok, cond, toks[done_at + 1 :] ) quoted = mask[i + 1 : i + 1 + len(cond)] if mask else None if sleeps and not bounded and not _bound_in_condition(cond, quoted): @@ -5422,6 +5424,11 @@ def classify( "allow", "while a redirect from a file names a source that ends", ), + ( + "until read l; do sleep 30; done < f", + "deny", + "but an until loop over that same source never ends once the input is exhausted", + ), ( "timeout 600 bash -c '(while true; do sleep 30; done) &'", "deny",