Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions host-setup/agent-safety/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <words>` is bounded by its own word list. The third is a loop whose condition is a
`while true` does, while a `for x in <words>` 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.
Expand Down Expand Up @@ -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"]
Expand Down
13 changes: 10 additions & 3 deletions host-setup/agent-safety/claude/gh-write-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand 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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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",
Expand Down
Loading