From 21f98b0efccb0bbadf906837c8149f82fa274d78 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 27 Sep 2026 20:19:50 -0700 Subject: [PATCH 1/7] Track Bash's Nested Contexts in the Guard's Shell-Token Fallback When shlex cannot follow a command's quoting, the write guard now reads it with a context-tracking scan rather than a comment strip guarded by a skip list. The scan carries quote state across lines, honors backslash escapes, reads $( ), ${ }, $'...', $"..." and backquote spans whole, drops a comment only outside every quote, and treats # as text inside arithmetic, an extglob pattern, a [[ =~ ]] operand, and a heredoc body. The union with the per-line base tokens is gone, so loop text inside a multi-line quote no longer draws a false deny. Closes on promotion: #1910 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 455 ++++++++++++++++-- 1 file changed, 406 insertions(+), 49 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 3cdf2255b..ce93798da 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -349,36 +349,282 @@ def _current_push_branch(cwd): # The string is the form shlex takes the set in, and the set is derived from it so the two cannot drift apart. _PUNCTUATION_CHARS = "();<>|&\n" _SHELL_OP_CHARS = set(_PUNCTUATION_CHARS) -_COMMENT_SCAN_BAIL = ("\\", "`", "$(", "${", "$'", '$"', "<<", "((") -_EXTGLOB_OPEN = re.compile(r"[@?*+!]\(") +_EXTGLOB_PREFIX = "@?*+!" +_DQ_ESCAPE = re.compile(r'\\([$`"\\\n])') -def _strip_comments(cmd): - """Return `cmd` with every comment bash reads cut away, or None where it holds none or holds - quoting, a heredoc, or a nested context such as arithmetic or an extglob pattern, where a `#` - is text, that this scan does not model. Only plain quotes are tracked, and their state - carries across lines as bash's does, so a `#` inside a quote an earlier line opened stays text. +def _skip_backticks(cmd, i): + """Index just past the backquote closing a command substitution whose body starts at `i`.""" + while i < len(cmd): + if cmd[i] == "\\": + i += 2 + elif cmd[i] == "`": + return i + 1 + else: + i += 1 + raise ValueError("unterminated backquote") + + +def _skip_quote_body(cmd, i, closer): + """Index just past the `closer` ending a quoted span whose body starts at `i`, for a double-quoted + or `$'...'` span. Both honor a backslash escape, and a double-quoted span also holds nested + substitutions, each skipped whole, since a quote inside one does not end the outer span. """ - if any(s in cmd for s in _COMMENT_SCAN_BAIL) or _EXTGLOB_OPEN.search(cmd): - return None - out = [] - quote = None - comment = found = False - for i, ch in enumerate(cmd): - if comment: - if ch != "\n": + while i < len(cmd): + ch = cmd[i] + if ch == "\\": + i += 2 + elif ch == closer: + return i + 1 + elif closer == '"' and (ch == "`" or cmd.startswith(("$(", "${"), i)): + i = _skip_nested(cmd, i) + else: + i += 1 + raise ValueError("unterminated quote") + + +def _skip_nested(cmd, i): + """Index just past the quote, escape, or substitution opening at `i`, or None where none does.""" + ch = cmd[i] + if ch == "\\": + return i + 2 + if ch == "'": + end = cmd.find("'", i + 1) + if end < 0: + raise ValueError("unterminated quote") + return end + 1 + if ch == '"': + return _skip_quote_body(cmd, i + 1, '"') + if ch == "`": + return _skip_backticks(cmd, i + 1) + if cmd.startswith("$'", i): + return _skip_quote_body(cmd, i + 2, "'") + if cmd.startswith('$"', i): + return _skip_quote_body(cmd, i + 2, '"') + if cmd.startswith("$(", i): + return _skip_group(cmd, i + 2, "(", ")", comments=True) + if cmd.startswith("${", i): + return _skip_group(cmd, i + 2, "{", "}", comments=False) + return None + + +def _skip_group(cmd, i, opener, closer, comments): + """Index just past the `closer` ending a group whose body starts at `i`. Bash reads the body in a + fresh quoting context, so a quote the outer text opened does not carry into it. With `comments`, + as in a command substitution, a `#` opening a word hides the rest of its line, a `closer` + included. A parameter expansion or an extglob pattern passes False, since a `#` there is text. + """ + depth = 0 + while i < len(cmd): + nxt = _skip_nested(cmd, i) + if nxt is not None: + i = nxt + continue + ch = cmd[i] + if ch == opener: + depth += 1 + elif ch == closer: + if not depth: + return i + 1 + depth -= 1 + elif comments and ch == "#" and (cmd[i - 1] in " \t\r" or cmd[i - 1] in _SHELL_OP_CHARS): + end = cmd.find("\n", i) + if end < 0: + break + i = end + continue + i += 1 + raise ValueError("unterminated group") + + +def _arith_depth(run, depth): + """The arithmetic nesting after an operator `run`, None outside `(( ))`, else the open parens.""" + k = 0 + while k < len(run): + if depth is None: + if run.startswith("((", k): + depth = 0 + k += 2 + continue + elif run[k] == "(": + depth += 1 + elif run[k] == ")": + if depth: + depth -= 1 + elif run.startswith("))", k): + depth = None + k += 2 continue - comment = False - elif quote: - if ch == quote: - quote = None - elif ch in "'\"": - quote = ch - elif ch == "#" and (i == 0 or cmd[i - 1] in " \t" or cmd[i - 1] in _SHELL_OP_CHARS): - comment = found = True + k += 1 + return depth + + +def _heredoc_line_tokens(line): + """Tokenize one heredoc body line. A `#` is text in a body, so it is read that way first. A body + fed to a shell is a script, though, where a `#` does open a comment, so a line that does not parse + as text is read again with comments, and then plainly, rather than dropped. + """ + for comments in (False, True): + try: + return _context_lex(line, comments) + except (ValueError, RecursionError): + pass + return _operator_tokens(line) + + +def _context_lex(cmd, comments=True): + """Tokenize `cmd` as `_operator_lex` does, tracking the contexts bash nests rather than only plain + quotes. Quote state carries across lines, a backslash escapes, and a substitution or `$'...'` span + is read whole, so a quote inside `"$(...)"` does not end the outer one. A `#` opening a word + outside every quote is a comment and dropped, with `comments`, and is text inside arithmetic, an + extglob pattern, a `[[ =~ ]]` operand, and a heredoc body. A `=~` operand is one word holding + `|`, parens, and spaces inside parens, as bash reads it. A `!(` opening a word is a negated + subshell rather than a pattern, since bash runs it so unless extglob is on. A `-` glued to `<<` + is the dash form, where a spaced one is the delimiter itself. Raises ValueError where a quote or + group never closes, which bash also rejects. + """ + toks, word, heredocs = [], [], [] + started = False + word_at = 0 + arith = None # paren depth inside `(( ))`, None outside it + in_cond = False # inside `[[ ]]` + regex = None # paren depth inside a `=~` operand, None outside one + tag_wanted = None # (index the `<<` ended at, dash form) until its delimiter word arrives + + def flush(): + nonlocal started, in_cond, regex, tag_wanted + if not started: + return + tok = "".join(word) + word.clear() + started = False + toks.append(tok) + if tag_wanted is not None: + end, dash = tag_wanted + if word_at == end and tok.startswith("-"): + dash, tok = True, tok[1:] + if tok: + heredocs.append((tok, dash)) + tag_wanted = None + else: + tag_wanted = (-1, dash) + regex = 0 if in_cond and tok == "=~" else None + if tok == "[[": + in_cond = True + elif tok == "]]": + in_cond = False + + n = len(cmd) + i = 0 + while i < n: + ch = cmd[i] + if regex is not None and ( + ch in "(|<>#" or (ch == ")" and regex) or (regex and ch in " \t\r") + ): + regex += (ch == "(") - (ch == ")") + if not started: + word_at = i + started = True + word.append(ch) + i += 1 + continue + if ch in " \t\r": + flush() + i += 1 + continue + if ch == "#" and comments and not started and arith is None: + end = cmd.find("\n", i) + i = n if end < 0 else end + continue + if ( + ch == "(" + and started + and arith is None + and word + and word[-1] == cmd[i - 1] + and cmd[i - 1] in _EXTGLOB_PREFIX + and cmd[i - 2 : i - 1] != "\\" + and not (cmd[i - 1] == "!" and word_at == i - 1) + ): + end = _skip_group(cmd, i + 1, "(", ")", comments=False) + word.append(cmd[i:end]) + i = end + continue + if ch in _SHELL_OP_CHARS: + flush() + j = i + while j < n and cmd[j] in _SHELL_OP_CHARS: + j += 1 + if cmd[j - 1] == "\n" and heredocs: + break + run = cmd[i:j] + toks.append(run) + regex = None + if arith is None and "<<" in run and "<<<" not in run: + tag_wanted = (j, False) + arith = _arith_depth(run, arith) + i = j + if "\n" in run: + tag_wanted = None + if heredocs: + i = _heredoc_bodies(cmd, i, heredocs, toks) + heredocs.clear() + continue + if ch == "\\" and cmd[i + 1 : i + 2] == "\n": + i += 2 continue - out.append(ch) - return "".join(out) if found else None + if not started: + word_at = i + started = True + if ch == "\\": + if i + 1 >= n: + raise ValueError("no escaped character") + word.append(cmd[i + 1]) + i += 2 + elif ch == "'": + end = _skip_nested(cmd, i) + word.append(cmd[i + 1 : end - 1]) + i = end + elif ch == '"' or cmd.startswith(('$"', "$'"), i): + body = i + (1 if ch == '"' else 2) + end = _skip_quote_body(cmd, body, cmd[body - 1]) + text = cmd[body : end - 1] + word.append( + _DQ_ESCAPE.sub(lambda m: m.group(1).strip("\n"), text) + if cmd[body - 1] == '"' + else text + ) + i = end + elif ch == "`" or cmd.startswith("${", i): + end = _skip_nested(cmd, i) + word.append(cmd[i:end]) + i = end + else: + word.append(ch) + i += 1 + flush() + return toks + + +def _heredoc_bodies(cmd, i, heredocs, toks): + """Tokenize the heredoc bodies starting at `i`, each through its delimiter line, into `toks`, + and return the index just past the last one. Bash reads a body line by line rather than as part + of the command, so each line is tokenized alone and a quote in one never spans into the next. + """ + n = len(cmd) + for tag, dash in heredocs: + while i < n: + end = cmd.find("\n", i) + if end < 0: + end = n + line = cmd[i:end] + toks.extend(_heredoc_line_tokens(line)) + if end < n: + toks.append("\n") + i = end + 1 + if (line.lstrip("\t") if dash else line) == tag: + break + return min(i, n) def _operator_lex(text): @@ -409,38 +655,22 @@ def _operator_tokens(line): return line.split() -def _base_tokens(cmd): - """Split each line of `cmd` plainly, keeping a newline between lines, as the base fallback did.""" - toks = [] - for i, line in enumerate(cmd.split("\n")): - if i: - toks.append("\n") - try: - toks.extend(shlex.split(line, posix=True)) - except ValueError: - toks.extend(line.split()) - return toks - - def _shell_tokens(cmd, strip_comments=True): """Tokenize like a shell, isolating operator runs (`|`, `&&`, `;`, newline, `>`, `2>&1`, ...) as their own tokens even when glued to a word - so a `>` or a newline inside a quoted value stays part - of that token while a real redirection or line break is separated. A command that does not parse - only because of a comment, an apostrophe in it being the usual case, is tokenized without its - comments, followed by the base fallback's tokens, so a context where bash reads a `#` as text - rather than a comment can never hide a command the base fallback saw. Degrades gracefully if - the quoting still cannot be parsed. + of that token while a real redirection or line break is separated. A command whose quoting shlex + cannot follow, an apostrophe in a comment or a quote nested in `"$(...)"` being the usual cases, + is read by `_context_lex`, dropping its comments unless `strip_comments` is False. Degrades to one + line at a time where the quoting still cannot be parsed. """ try: return _operator_lex(cmd) except (ValueError, TypeError): # bad quoting, or punctuation_chars unsupported on old Python pass - stripped = _strip_comments(cmd) if strip_comments else None - if stripped is not None: - try: - return [*_operator_lex(stripped), "\n", *_base_tokens(cmd)] - except (ValueError, TypeError): - pass + try: + return _context_lex(cmd, strip_comments) + except (ValueError, RecursionError): + pass toks = [] for i, line in enumerate(cmd.split("\n")): if i: @@ -3876,6 +4106,51 @@ def classify( "deny", "a heredoc marker inside an unclosed quote opens no heredoc, so the loop after the quote closes is seen", ), + ( + "echo \"$(date)\" 'x\nwhile true; do sleep 5; done\n' # it's", + "allow", + "loop text inside a quote an earlier line opened is text, not a loop, beside a substitution and an apostrophe", + ), + ( + "while ! [[ $x =~ a|#b ]]; do sleep 30; done # the PR's checks", + "deny", + "a `#` inside a `=~` operand is text, so the loop around the test is seen", + ), + ( + "while true; do sleep 1; done # it's \\note", + "deny", + "a backslash inside the comment itself does not hide the loop before it", + ), + ( + 'echo "$(date)"; while ! gh pr checks 5 --watch; do sleep 30; done # it\'s', + "deny", + "a command substitution elsewhere on the line does not hide the loop", + ), + ( + 'echo "$(echo "\'")"; while true; do sleep 5; done; echo "\'" # it\'s', + "deny", + "a double quote nested in a substitution inside a double quote does not hide the loop", + ), + ( + "echo ${x#a}; while true; do sleep 1; done # it's", + "deny", + "a `#` inside a parameter expansion is text, so the loop after it is seen", + ), + ( + "echo @(a|#b); while true; do sleep 1; done # it's", + "deny", + "a `#` inside an extglob pattern is text, so the loop after it is seen", + ), + ( + "(( x = 16#ff )); while true; do sleep 1; done # it's", + "deny", + "a `#` inside arithmetic is text, so the loop after it is seen", + ), + ( + "echo `date`; while true; do sleep 1; done # it's", + "deny", + "a backquote substitution elsewhere on the line does not hide the loop", + ), ( "bash -c 'until [ -f /tmp/done ]; do sleep 10; done'", "deny", @@ -4375,6 +4650,79 @@ def classify( ] +_CONTEXT_LEX_CASES = [ + ( + "echo \"$(date)\" 'x\nwhile true\n' # it's", + ["echo", "$(date)", "x\nwhile true\n"], + "a quote carries across lines", + ), + ( + "[[ $x =~ a|#b ]] # it's", + ["[[", "$x", "=~", "a|#b", "]]"], + "a `=~` operand holds `|` and `#`", + ), + ( + "[[ $x =~ (a b|c) ]]; y # it's", + ["[[", "$x", "=~", "(a b|c)", "]]", ";", "y"], + "a `=~` operand holds spaces inside parens", + ), + ( + "[[ ( $x =~ a ) ]] # it's", + ["[[", "(", "$x", "=~", "a", ")", "]]"], + "a `)` outside the operand's parens ends it", + ), + ("echo ${x#a} # it's", ["echo", "${x#a}"], "a parameter expansion is read whole"), + ("(( x = 16#ff )) # it's", ["((", "x", "=", "16#ff", "))"], "a `#` inside arithmetic is text"), + ("echo @(a|#b) # it's", ["echo", "@(a|#b)"], "an extglob pattern is one word"), + ("!(x) # it's", ["!", "(", "x", ")"], "a `!(` opening a word is a negated subshell"), + ( + 'echo "$(echo "\'")" # it\'s', + ["echo", '$(echo "\'")'], + "a substitution's quotes start over inside it", + ), + ( + "echo \"$(x # it's\n)\" y # it's", + ["echo", "$(x # it's\n)", "y"], + "a comment inside a substitution hides its `)`", + ), + ( + "cat <<-EOF\n\tit's\n\tEOF\necho 'a\nb' # it's", + ["cat", "<<", "-EOF", "\n", "it's", "\n", "EOF", "\n", "echo", "a\nb"], + "a heredoc body line is read alone, and the dash form's tabbed delimiter ends the body", + ), + ( + "cat < Date: Sun, 27 Sep 2026 20:31:43 -0700 Subject: [PATCH 2/7] Close Four Hiding Paths the Local Review Found in the Context Scan A heredoc inside a command substitution is skipped line by line, so a quote in its body no longer spans past the substitution. A heredoc opener counts only where the line opens one both with its comments dropped and with a # read as text. A $$ is the process id rather than the start of a $'...' span, and [[ opens a conditional only unquoted and in command position. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 116 ++++++++++++++++-- 1 file changed, 105 insertions(+), 11 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index ce93798da..81ab45d6d 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -350,6 +350,7 @@ def _current_push_branch(cwd): _PUNCTUATION_CHARS = "();<>|&\n" _SHELL_OP_CHARS = set(_PUNCTUATION_CHARS) _EXTGLOB_PREFIX = "@?*+!" +_COND_PRECEDERS = {"!", "if", "then", "elif", "else", "while", "until", "do", "time", "{"} _DQ_ESCAPE = re.compile(r'\\([$`"\\\n])') @@ -376,6 +377,8 @@ def _skip_quote_body(cmd, i, closer): i += 2 elif ch == closer: return i + 1 + elif closer == '"' and cmd.startswith("$$", i): + i += 2 elif closer == '"' and (ch == "`" or cmd.startswith(("$(", "${"), i)): i = _skip_nested(cmd, i) else: @@ -383,6 +386,24 @@ def _skip_quote_body(cmd, i, closer): raise ValueError("unterminated quote") +_HEREDOC_IN_GROUP = re.compile( + r"<<(?!<)(-?)[ \t]*(?:'([^'\n]*)'|\"([^\"\n]*)\"|\\?([A-Za-z_][A-Za-z0-9_.\-]*))" +) + + +def _skip_heredoc_lines(cmd, i, heredocs): + """Index just past the delimiter line of the last of `heredocs`, their bodies starting at `i`.""" + for tag, dash in heredocs: + while i < len(cmd): + end = cmd.find("\n", i) + end = len(cmd) if end < 0 else end + line = cmd[i:end] + i = end + 1 + if (line.lstrip("\t") if dash else line) == tag: + break + return min(i, len(cmd)) + + def _skip_nested(cmd, i): """Index just past the quote, escape, or substitution opening at `i`, or None where none does.""" ch = cmd[i] @@ -397,6 +418,10 @@ def _skip_nested(cmd, i): return _skip_quote_body(cmd, i + 1, '"') if ch == "`": return _skip_backticks(cmd, i + 1) + if cmd.startswith("$$", i): + return i + 2 + if cmd.startswith("$((", i): + return _skip_group(cmd, i + 2, "(", ")", comments=False) if cmd.startswith("$'", i): return _skip_quote_body(cmd, i + 2, "'") if cmd.startswith('$"', i): @@ -412,10 +437,24 @@ def _skip_group(cmd, i, opener, closer, comments): """Index just past the `closer` ending a group whose body starts at `i`. Bash reads the body in a fresh quoting context, so a quote the outer text opened does not carry into it. With `comments`, as in a command substitution, a `#` opening a word hides the rest of its line, a `closer` - included. A parameter expansion or an extglob pattern passes False, since a `#` there is text. + included, and a heredoc's body is skipped line by line to its delimiter, so a quote in the body + does not span past it. A parameter expansion, arithmetic, or an extglob pattern passes False, + since a `#` there is text and a `<<` is no heredoc. """ depth = 0 + pending = [] while i < len(cmd): + opened = _HEREDOC_IN_GROUP.match(cmd, i) if comments else None + if opened: + pending.append( + (next(t for t in opened.group(2, 3, 4) if t is not None), bool(opened.group(1))) + ) + i = opened.end() + continue + if cmd[i] == "\n" and pending: + i = _skip_heredoc_lines(cmd, i + 1, pending) + pending.clear() + continue nxt = _skip_nested(cmd, i) if nxt is not None: i = nxt @@ -484,7 +523,7 @@ def _context_lex(cmd, comments=True): group never closes, which bash also rejects. """ toks, word, heredocs = [], [], [] - started = False + started = quoted = False word_at = 0 arith = None # paren depth inside `(( ))`, None outside it in_cond = False # inside `[[ ]]` @@ -492,12 +531,14 @@ def _context_lex(cmd, comments=True): tag_wanted = None # (index the `<<` ended at, dash form) until its delimiter word arrives def flush(): - nonlocal started, in_cond, regex, tag_wanted + nonlocal started, quoted, in_cond, regex, tag_wanted if not started: return tok = "".join(word) + bare = not quoted word.clear() - started = False + started = quoted = False + at_command = not toks or _is_shell_op(toks[-1]) or toks[-1] in _COND_PRECEDERS toks.append(tok) if tag_wanted is not None: end, dash = tag_wanted @@ -508,10 +549,10 @@ def flush(): tag_wanted = None else: tag_wanted = (-1, dash) - regex = 0 if in_cond and tok == "=~" else None - if tok == "[[": + regex = 0 if in_cond and bare and tok == "=~" else None + if tok == "[[" and bare and at_command: in_cond = True - elif tok == "]]": + elif tok == "]]" and bare: in_cond = False n = len(cmd) @@ -576,16 +617,22 @@ def flush(): if not started: word_at = i started = True + if ch != "$" and ch in "\\'\"`": + quoted = True if ch == "\\": if i + 1 >= n: raise ValueError("no escaped character") word.append(cmd[i + 1]) i += 2 + elif cmd.startswith("$$", i): + word.append("$$") + i += 2 elif ch == "'": end = _skip_nested(cmd, i) word.append(cmd[i + 1 : end - 1]) i = end elif ch == '"' or cmd.startswith(('$"', "$'"), i): + quoted = True body = i + (1 if ch == '"' else 2) end = _skip_quote_body(cmd, body, cmd[body - 1]) text = cmd[body : end - 1] @@ -655,12 +702,12 @@ def _operator_tokens(line): return line.split() -def _shell_tokens(cmd, strip_comments=True): +def _shell_tokens(cmd): """Tokenize like a shell, isolating operator runs (`|`, `&&`, `;`, newline, `>`, `2>&1`, ...) as their own tokens even when glued to a word - so a `>` or a newline inside a quoted value stays part of that token while a real redirection or line break is separated. A command whose quoting shlex cannot follow, an apostrophe in a comment or a quote nested in `"$(...)"` being the usual cases, - is read by `_context_lex`, dropping its comments unless `strip_comments` is False. Degrades to one + is read by `_context_lex`, which drops its comments. Degrades to one line at a time where the quoting still cannot be parsed. """ try: @@ -668,7 +715,7 @@ def _shell_tokens(cmd, strip_comments=True): except (ValueError, TypeError): # bad quoting, or punctuation_chars unsupported on old Python pass try: - return _context_lex(cmd, strip_comments) + return _context_lex(cmd) except (ValueError, RecursionError): pass toks = [] @@ -2373,8 +2420,25 @@ def _heredoc_opener(line): line carrying arithmetic is skipped outright, since `$(( 1 << n ))` tokenizes to a bare `<<` that no token test can tell from a redirection. Skipping there costs a false deny on a line holding both, which is vanishingly rare, where reading it wrong drops real commands. + + The line is read twice, with its comments dropped and with a `#` read as text, and opens a + heredoc only where both readings find one. The first keeps a `<<` inside a comment from opening + one. The second keeps a line an earlier line's quote holds from opening one, since a quote + there usually leaves the line unparseable as text. """ - toks = _shell_tokens(line, strip_comments=False) + readings = [] + for comments in (False, True): + try: + readings.append(_context_lex(line, comments)) + except (ValueError, RecursionError): + readings.append(_operator_tokens(line)) + if _heredoc_opener_in(readings[0]) is None: + return None + return _heredoc_opener_in(readings[1]) + + +def _heredoc_opener_in(toks): + """(tag, is_dash_form, feeds_a_shell) for the heredoc a line's `toks` open, or None.""" if any("((" in t for t in toks): return None for k, tok in enumerate(toks): @@ -4151,6 +4215,26 @@ def classify( "deny", "a backquote substitution elsewhere on the line does not hide the loop", ), + ( + "printf %s \"$(cat <<'EOF'\nIt's done.\nEOF\n)\"\nwhile true; do sleep 5; done\necho \"Fix (it's broken)\" # don't", + "deny", + "a quote in a heredoc body inside a substitution does not span past the body", + ), + ( + 'printf \'%s\\n\' "$(git log -1 --format="%an\'s")" # the < Date: Sun, 27 Sep 2026 20:44:05 -0700 Subject: [PATCH 3/7] Close Eight More Hiding Paths the Second Local Review Found A case pattern's ) no longer closes a command substitution, and a (( that closes on a lone ) is read as nested subshells rather than arithmetic. An empty quoted heredoc delimiter opens a heredoc, and a herestring inside a substitution does not. A carriage return is a word character, a # glued to a substitution's ) is text, [[ is in command position only after a separator or a keyword that was itself in command position, and a { inside ${...} does not nest. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 107 +++++++++++++++--- 1 file changed, 90 insertions(+), 17 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 81ab45d6d..f1a823a4e 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -350,6 +350,8 @@ def _current_push_branch(cwd): _PUNCTUATION_CHARS = "();<>|&\n" _SHELL_OP_CHARS = set(_PUNCTUATION_CHARS) _EXTGLOB_PREFIX = "@?*+!" +_WORD_BREAK_CHARS = " \t\n;&|<>(" +_CASE_KEYWORD = re.compile(r"(?:case|esac)(?=[ \t\n;&|<>()]|$)") _COND_PRECEDERS = {"!", "if", "then", "elif", "else", "while", "until", "do", "time", "{"} _DQ_ESCAPE = re.compile(r'\\([$`"\\\n])') @@ -387,7 +389,7 @@ def _skip_quote_body(cmd, i, closer): _HEREDOC_IN_GROUP = re.compile( - r"<<(?!<)(-?)[ \t]*(?:'([^'\n]*)'|\"([^\"\n]*)\"|\\?([A-Za-z_][A-Za-z0-9_.\-]*))" + r"(?#" or (ch == ")" and regex) or (regex and ch in " \t\r") + ch in "(|<>#" or (ch == ")" and regex) or (regex and ch in " \t") ): regex += (ch == "(") - (ch == ")") if not started: @@ -569,11 +596,11 @@ def flush(): word.append(ch) i += 1 continue - if ch in " \t\r": + if ch in " \t": flush() i += 1 continue - if ch == "#" and comments and not started and arith is None: + if ch == "#" and comments and not started and arith is None and cmd[i - 1 : i] != ")": end = cmd.find("\n", i) i = n if end < 0 else end continue @@ -603,7 +630,8 @@ def flush(): regex = None if arith is None and "<<" in run and "<<<" not in run: tag_wanted = (j, False) - arith = _arith_depth(run, arith) + arith = _arith_depth(run, arith, cmd, i) + cmd_pos = not _is_redir_op(run) i = j if "\n" in run: tag_wanted = None @@ -4235,6 +4263,46 @@ def classify( "deny", "a quoted `[[` argument opens no conditional, so a later `=~` swallows nothing", ), + ( + "echo \"$(case $x in a) echo \"'\";; esac)\"\nwhile true; do sleep 1; done\n# it's $'\\''", + "deny", + "a `case` pattern's `)` inside a substitution does not close it", + ), + ( + "((git fetch) && echo x) # it's\nwhile true; do sleep 1; done\n# it's $'\\''", + "deny", + "a `((` that closes on a lone `)` is nested subshells, not arithmetic", + ), + ( + "cat <<''\nit's\n\nwhile true; do sleep 1; done\n# it's $'\\''", + "deny", + "an empty quoted heredoc delimiter still opens a heredoc, ending at the first empty line", + ), + ( + "out=\"$(tr a b <<<'EOF'\n)\"\nwhile true; do sleep 1; done\nEOF\n)\" # it's $'\\''", + "deny", + "a herestring inside a substitution is not a heredoc", + ), + ( + "echo a\r# ; while true; do sleep 1; done\n# it's", + "deny", + "a carriage return is a word character, so a `#` after one opens no comment", + ), + ( + "echo $(date)#x ; while true; do sleep 1; done\n# it's", + "deny", + "a `#` glued to a substitution's `)` is text, not a comment", + ), + ( + "echo do [[ x =~ a|while true; do sleep 1; done # it's", + "deny", + "a keyword spelled as an argument does not put a later `[[` in command position", + ), + ( + "echo ${x:-{}\nwhile true; do sleep 1; done\necho } # it's $'\\''", + "deny", + "a `{` inside a parameter expansion does not nest, so its first `}` closes it", + ), ( "bash -c 'until [ -f /tmp/done ]; do sleep 10; done'", "deny", @@ -4745,6 +4813,11 @@ def classify( ["[[", "$x", "=~", "a|#b", "]]"], "a `=~` operand holds `|` and `#`", ), + ( + "x; [[ $x =~ a|#b ]] # it's", + ["x", ";", "[[", "$x", "=~", "a|#b", "]]"], + "a `[[` after a separator opens a conditional", + ), ( "[[ $x =~ (a b|c) ]]; y # it's", ["[[", "$x", "=~", "(a b|c)", "]]", ";", "y"], From 9f2eca7cc81c5e5e33385155baea6bae63c9b299 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 28 Sep 2026 17:39:39 -0700 Subject: [PATCH 4/7] Keep the Line Fallback's Tokens Beside the Context Scan's Restore the union with the fallback reading that predates the context scan, so a context the scan misreads never hides a command that reading saw, and a heredoc opens only where that reading also opens it. Build the paren closers once per command, so a long run of `((` pairs is scanned in linear time. The multi-line-quote false deny stays open, per the answer on #1960. Co-Authored-By: Claude Opus 5.5 --- .../agent-safety/claude/gh-write-guard.py | 145 +++++++++++++++--- 1 file changed, 121 insertions(+), 24 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 00ee4ccd0..466171e8e 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -86,6 +86,7 @@ Run `gh-write-guard.py --selftest` to verify the decision matrix without Claude Code. """ +import functools import json import os import posixpath @@ -93,6 +94,7 @@ import shlex import subprocess import sys +import time from urllib.parse import quote, urlsplit # --- What counts as a GitHub write ------------------------------------------------------------------- @@ -354,6 +356,8 @@ def _current_push_branch(cwd): _CASE_KEYWORD = re.compile(r"(?:case|esac)(?=[ \t\n;&|<>()]|$)") _COND_PRECEDERS = {"!", "if", "then", "elif", "else", "while", "until", "do", "time", "{"} _DQ_ESCAPE = re.compile(r'\\([$`"\\\n])') +_COMMENT_SCAN_BAIL = ("\\", "`", "$(", "${", "$'", '$"', "<<", "((") +_EXTGLOB_OPEN = re.compile(r"[@?*+!]\(") def _skip_backticks(cmd, i): @@ -509,18 +513,24 @@ def _arith_depth(run, depth, cmd, at): return depth +@functools.lru_cache(maxsize=4) +def _paren_closers(cmd): + """Map each `(` in `cmd` to the index of the `)` closing it, counting raw characters. Built in + one pass and cached, since a run of `((` asks once per pair and a scan per ask is quadratic. + """ + closers, stack = {}, [] + for i, ch in enumerate(cmd): + if ch == "(": + stack.append(i) + elif ch == ")" and stack: + closers[stack.pop()] = i + return closers + + def _closes_as_arithmetic(cmd, i): """True where the parens opened just before `cmd[i]` close together, on `))`.""" - depth = 0 - while i < len(cmd): - if cmd[i] == "(": - depth += 1 - elif cmd[i] == ")": - if not depth: - return cmd[i + 1 : i + 2] == ")" - depth -= 1 - i += 1 - return True + close = _paren_closers(cmd).get(i - 1) + return close is None or cmd[close + 1 : close + 2] == ")" def _heredoc_line_tokens(line): @@ -733,28 +743,84 @@ def _operator_tokens(line): return line.split() +def _strip_comments(cmd): + """Return `cmd` with every comment bash reads cut away, or None where it holds none or holds + quoting, a heredoc, or a nested context such as arithmetic or an extglob pattern, where a `#` + is text, that this scan does not model. Only plain quotes are tracked, and their state + carries across lines as bash's does, so a `#` inside a quote an earlier line opened stays text. + """ + if any(s in cmd for s in _COMMENT_SCAN_BAIL) or _EXTGLOB_OPEN.search(cmd): + return None + out = [] + quote = None + comment = found = False + for i, ch in enumerate(cmd): + if comment: + if ch != "\n": + continue + comment = False + elif quote: + if ch == quote: + quote = None + elif ch in "'\"": + quote = ch + elif ch == "#" and (i == 0 or cmd[i - 1] in " \t" or cmd[i - 1] in _SHELL_OP_CHARS): + comment = found = True + continue + out.append(ch) + return "".join(out) if found else None + + +def _base_tokens(cmd): + """Split each line of `cmd` plainly, keeping a newline between lines.""" + toks = [] + for i, line in enumerate(cmd.split("\n")): + if i: + toks.append("\n") + try: + toks.extend(shlex.split(line, posix=True)) + except ValueError: + toks.extend(line.split()) + return toks + + +def _line_fallback_tokens(cmd): + """The reading that predates `_context_lex`: the command with its comments stripped, followed + by each line split plainly, or where the strip does not apply, each line tokenized alone. + """ + stripped = _strip_comments(cmd) + if stripped is not None: + try: + return [*_operator_lex(stripped), "\n", *_base_tokens(cmd)] + except (ValueError, TypeError): + pass + toks = [] + for i, line in enumerate(cmd.split("\n")): + if i: + toks.append("\n") + toks.extend(_operator_tokens(line)) + return toks + + def _shell_tokens(cmd): """Tokenize like a shell, isolating operator runs (`|`, `&&`, `;`, newline, `>`, `2>&1`, ...) as their own tokens even when glued to a word - so a `>` or a newline inside a quoted value stays part of that token while a real redirection or line break is separated. A command whose quoting shlex cannot follow, an apostrophe in a comment or a quote nested in `"$(...)"` being the usual cases, - is read by `_context_lex`, which drops its comments. Degrades to one - line at a time where the quoting still cannot be parsed. + is read both by `_line_fallback_tokens` and by `_context_lex`, and the tokens of both are + returned. The union means a context the scan misreads can never hide a command the line + fallback saw, at the cost of the line fallback's false denies, such as loop text inside a + quote an earlier line opened. """ try: return _operator_lex(cmd) except (ValueError, TypeError): # bad quoting, or punctuation_chars unsupported on old Python pass + toks = _line_fallback_tokens(cmd) try: - return _context_lex(cmd) + return [*toks, "\n", *_context_lex(cmd)] except (ValueError, RecursionError): - pass - toks = [] - for i, line in enumerate(cmd.split("\n")): - if i: - toks.append("\n") - toks.extend(_operator_tokens(line)) - return toks + return toks def _is_shell_op(tok): @@ -2563,7 +2629,9 @@ def _heredoc_opener(line): The line is read twice, with its comments dropped and with a `#` read as text, and opens a heredoc only where both readings find one. The first keeps a `<<` inside a comment from opening one. The second keeps a line an earlier line's quote holds from opening one, since a quote - there usually leaves the line unparseable as text. + there usually leaves the line unparseable as text. Both must also agree with the line read + alone by `_operator_tokens`, as it was before `_context_lex`, so a context the scan misreads + never strips a body that reading kept. """ readings = [] for comments in (False, True): @@ -2573,7 +2641,8 @@ def _heredoc_opener(line): readings.append(_operator_tokens(line)) if _heredoc_opener_in(readings[0]) is None: return None - return _heredoc_opener_in(readings[1]) + opener = _heredoc_opener_in(readings[1]) + return opener if opener == _heredoc_opener_in(_operator_tokens(line)) else None def _heredoc_opener_in(toks): @@ -4311,8 +4380,28 @@ def classify( ), ( "echo \"$(date)\" 'x\nwhile true; do sleep 5; done\n' # it's", - "allow", - "loop text inside a quote an earlier line opened is text, not a loop, beside a substitution and an apostrophe", + "deny", + "loop text inside a quote an earlier line opened still denies, since the line fallback's tokens are kept beside the scan's", + ), + ( + "(true)#'\nwhile true; do sleep 1; done\n#'\n# it's", + "deny", + "a `#` the scan misreads as text after a subshell's `)` does not hide the loop the line fallback sees", + ), + ( + "(true)#'\necho 'a\nb'; while true; do sleep 1; done # it's\n# don't", + "deny", + "a loop only the comment-stripped whole-command reading sees is still seen where the scan misreads a `#`", + ), + ( + 'echo "$(echo case) \'"\nwhile true; do sleep 1; done\necho "\' esac)" # it\'s', + "deny", + "a `case` argument the scan misreads as a keyword does not hide the loop the line fallback sees", + ), + ( + 'cat <= 5: + ok = False + print(f" {mark} [lex ] a long run of `((` pairs is scanned in linear time ({elapsed:.2f}s)") # The one case that spawns git rather than stubbing it, since what it covers is the decode inside that spawn. # A checkout whose path is not UTF-8 decoded strictly raised, which read as unresolvable, and the guard then allowed a mutating command in a primary checkout it had failed to recognize. got = _is_primary_checkout_selftest() From a8b5abf4bf6494203ffae7cf02ec54361cf367df Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 28 Sep 2026 17:57:45 -0700 Subject: [PATCH 5/7] Judge the Wait Rule on Both Heredoc Strips Strip heredoc bodies once by the scan's opener and once by the line reading's, and deny a loop either leaves standing, since vetoing one opener let a later body line open a heredoc that hid a loop. Build the paren closers once per scan rather than in a shared cache that heredoc body lines evicted, and drop a docstring claim the opener's fallback did not honor. Co-Authored-By: Claude Opus 5.5 --- .../agent-safety/claude/gh-write-guard.py | 87 ++++++++++++------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 466171e8e..cddc8e202 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -86,7 +86,6 @@ Run `gh-write-guard.py --selftest` to verify the decision matrix without Claude Code. """ -import functools import json import os import posixpath @@ -488,15 +487,16 @@ def _skip_group(cmd, i, opener, closer, comments): raise ValueError("unterminated group") -def _arith_depth(run, depth, cmd, at): +def _arith_depth(run, depth, cmd, at, closers): """The arithmetic nesting after an operator `run` found at `cmd[at]`, None outside `(( ))`, else the open parens. A `((` whose group closes on a lone `)` is nested subshells, as bash reads it. + `closers` is `_paren_closers(cmd)`. """ k = 0 while k < len(run): if depth is None: if run.startswith("((", k): - if _closes_as_arithmetic(cmd, at + k + 2): + if _closes_as_arithmetic(cmd, at + k + 2, closers): depth = 0 k += 2 continue @@ -513,10 +513,9 @@ def _arith_depth(run, depth, cmd, at): return depth -@functools.lru_cache(maxsize=4) def _paren_closers(cmd): """Map each `(` in `cmd` to the index of the `)` closing it, counting raw characters. Built in - one pass and cached, since a run of `((` asks once per pair and a scan per ask is quadratic. + one pass per command, since a run of `((` asks once per pair and a scan per ask is quadratic. """ closers, stack = {}, [] for i, ch in enumerate(cmd): @@ -527,9 +526,11 @@ def _paren_closers(cmd): return closers -def _closes_as_arithmetic(cmd, i): - """True where the parens opened just before `cmd[i]` close together, on `))`.""" - close = _paren_closers(cmd).get(i - 1) +def _closes_as_arithmetic(cmd, i, closers): + """True where the parens opened just before `cmd[i]` close together, on `))`, `closers` being + `_paren_closers(cmd)`. + """ + close = closers.get(i - 1) return close is None or cmd[close + 1 : close + 2] == ")" @@ -562,6 +563,7 @@ def _context_lex(cmd, comments=True): cmd_pos = True word_at = 0 arith = None # paren depth inside `(( ))`, None outside it + closers = None # `_paren_closers(cmd)`, built at the first operator run in_cond = False # inside `[[ ]]` regex = None # paren depth inside a `=~` operand, None outside one tag_wanted = None # (index the `<<` ended at, dash form) until its delimiter word arrives @@ -640,7 +642,9 @@ def flush(): regex = None if arith is None and "<<" in run and "<<<" not in run: tag_wanted = (j, False) - arith = _arith_depth(run, arith, cmd, i) + if closers is None: + closers = _paren_closers(cmd) + arith = _arith_depth(run, arith, cmd, i, closers) cmd_pos = not _is_redir_op(run) i = j if "\n" in run: @@ -2627,11 +2631,9 @@ def _heredoc_opener(line): holding both, which is vanishingly rare, where reading it wrong drops real commands. The line is read twice, with its comments dropped and with a `#` read as text, and opens a - heredoc only where both readings find one. The first keeps a `<<` inside a comment from opening - one. The second keeps a line an earlier line's quote holds from opening one, since a quote - there usually leaves the line unparseable as text. Both must also agree with the line read - alone by `_operator_tokens`, as it was before `_context_lex`, so a context the scan misreads - never strips a body that reading kept. + heredoc only where both readings find one, taking the first reading's. The first keeps a `<<` + inside a comment from opening one. A reading that does not parse is replaced by the line as + `_operator_tokens` reads it. """ readings = [] for comments in (False, True): @@ -2641,8 +2643,12 @@ def _heredoc_opener(line): readings.append(_operator_tokens(line)) if _heredoc_opener_in(readings[0]) is None: return None - opener = _heredoc_opener_in(readings[1]) - return opener if opener == _heredoc_opener_in(_operator_tokens(line)) else None + return _heredoc_opener_in(readings[1]) + + +def _line_heredoc_opener(line): + """`_heredoc_opener` as it read a line before `_context_lex`, through `_operator_tokens` alone.""" + return _heredoc_opener_in(_operator_tokens(line)) def _heredoc_opener_in(toks): @@ -2677,8 +2683,9 @@ def _heredoc_opener_in(toks): return None -def _strip_heredoc_bodies(cmd): +def _strip_heredoc_bodies(cmd, opener=_heredoc_opener): """`cmd` with every heredoc body removed, except one fed to a shell, which really is a script. + `opener` decides which lines open a heredoc. A heredoc body is data rather than a command line, so `cat > notes.md <= 5: - ok = False - print(f" {mark} [lex ] a long run of `((` pairs is scanned in linear time ({elapsed:.2f}s)") + for label, cmd in ( + ("a long run of `((` pairs", "(" * 20000 + "x" + " )" * 20000), + ("`((` in many heredoc bodies", "(( 1 ))\ncat <= 5: + ok = False + print(f" {mark} [lex ] {label} is scanned in linear time ({elapsed:.2f}s)") # The one case that spawns git rather than stubbing it, since what it covers is the decode inside that spawn. # A checkout whose path is not UTF-8 decoded strictly raised, which read as unresolvable, and the guard then allowed a mutating command in a primary checkout it had failed to recognize. got = _is_primary_checkout_selftest() From 751d0582ad4b1c1966fb9aa86ad7c686db9b4349 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 28 Sep 2026 18:26:12 -0700 Subject: [PATCH 6/7] State the Scan's Reading of a `#` After a `)` Name in the context scan's docstring that a `#` right after any `)` is kept as text, which bash reads as a comment after a subshell's `)`. Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/claude/gh-write-guard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index cddc8e202..69322ea97 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -552,7 +552,8 @@ def _context_lex(cmd, comments=True): quotes. Quote state carries across lines, a backslash escapes, and a substitution or `$'...'` span is read whole, so a quote inside `"$(...)"` does not end the outer one. A `#` opening a word outside every quote is a comment and dropped, with `comments`, and is text inside arithmetic, an - extglob pattern, a `[[ =~ ]]` operand, and a heredoc body. A `=~` operand is one word holding + extglob pattern, a `[[ =~ ]]` operand, and a heredoc body. A `#` right after any `)` is kept + as text, which bash agrees with after a substitution's `)` and not after a subshell's. A `=~` operand is one word holding `|`, parens, and spaces inside parens, as bash reads it. A `!(` opening a word is a negated subshell rather than a pattern, since bash runs it so unless extglob is on. A `-` glued to `<<` is the dash form, where a spaced one is the delimiter itself. Raises ValueError where a quote or From 656b9690a4087dcb7821dcf2523a45a254eb55aa Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 28 Sep 2026 21:11:37 -0700 Subject: [PATCH 7/7] Refuse an Operator as a Redirect's Target in the Wait Rule A dangling redirect at the end of the line fallback's reading took the newline that joins it to the context scan's reading as its target, so the scan's first statement's input redirect counted as the loop's bound. An operator is never a target, so the separator now ends the invocation, and a redirect with no target bounds nothing. Both cases are selftest wait cases. Co-Authored-By: Claude Opus 5.5 --- .../agent-safety/claude/gh-write-guard.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 9f0a46171..979be32ab 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -2271,7 +2271,11 @@ def _redirects_stdin(after_done): i += 1 continue target = after_done[i + 1] if i + 1 < len(after_done) else "" - i += 2 + if _is_shell_op(target): + target = "" + i += 1 + else: + i += 2 if "<" not in tok: continue # an output redirect leaves descriptor 0 where it was # A `{name}<` form names a variable rather than a literal and is never descriptor 0. @@ -2281,7 +2285,7 @@ def _redirects_stdin(after_done): continue # a redirect on another descriptor leaves descriptor 0 where it was # The last binding wins, since bash applies redirections in order and each replaces the last. # Returning on the first let `< in.txt < /dev/zero` vouch for the stream that actually binds. - bound = not ("&" in tok or _names_a_stream(target)) + bound = bool(target) and not ("&" in tok or _names_a_stream(target)) return bound @@ -5055,6 +5059,16 @@ def classify( "allow", "and binds a file just as the bare form does, rather than reading as another descriptor", ), + ( + '< f cat "$(echo "\'")" ; (true)#\'\nyes | while read l ; do sleep 30 ; done #\' >', + "deny", + "a dangling redirect never takes the next separator as its target and reads on to a `< f`", + ), + ( + "yes | while read l; do sleep 30; done <", + "deny", + "a redirect with no target names no source, so it bounds nothing", + ), ( "while read l; do sleep 30; done < f", "allow",