From a8b59eaaa7e634eb9b9c1e86eaff8bdf444996ff Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 14:22:06 -0700 Subject: [PATCH 1/7] Isolate Operators in the Guard's Shell-Token Fallback When the whole command's quoting cannot be parsed, such as an apostrophe in a trailing comment, the fallback now tokenizes each line with its operator runs isolated, the way the primary path does, so `30;` no longer fuses and hides the loop it ends from requirement 7 and every other token-based rule. An unterminated quote there is read as a literal character. Closes on promotion: #1635 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 78 +++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index fc38a0acb..a0d1007b7 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -351,6 +351,63 @@ def _current_push_branch(cwd): _SHELL_OP_CHARS = set(_PUNCTUATION_CHARS) +def _operator_tokens(text): + """Tokenize one line of text as `_shell_tokens` does, isolating operator runs, including where + its quoting cannot be parsed. An unterminated quote is read as a literal character there rather + than raising, since an apostrophe in a trailing `#` comment is the ordinary way to reach one. + """ + toks = [] + word = None + i = 0 + n = len(text) + while i < n: + c = text[i] + if c in _SHELL_OP_CHARS: + if word is not None: + toks.append(word) + word = None + j = i + while j < n and text[j] in _SHELL_OP_CHARS: + j += 1 + toks.append(text[i:j]) + i = j + continue + if c.isspace(): + if word is not None: + toks.append(word) + word = None + i += 1 + continue + word = word or "" + if c == "\\" and i + 1 < n: + word += text[i + 1] + i += 2 + elif c == "'" and "'" in text[i + 1 :]: + j = text.index("'", i + 1) + word += text[i + 1 : j] + i = j + 1 + elif c == '"': + j = i + 1 + quoted = "" + while j < n and text[j] != '"': + if text[j] == "\\" and j + 1 < n and text[j + 1] in '"\\': + j += 1 + quoted += text[j] + j += 1 + if j < n: + word += quoted + i = j + 1 + else: + word += c + i += 1 + else: + word += c + i += 1 + if word is not None: + toks.append(word) + 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 @@ -368,15 +425,11 @@ def _shell_tokens(cmd): ) # A newline is an operator above rather than a gap between words. return list(lex) except (ValueError, TypeError): # bad quoting, or punctuation_chars unsupported on old Python - # Neither fallback isolates an operator, so the lines are split here to keep the one thing this path must not lose, that a newline ends the command before it. 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()) + toks.extend(_operator_tokens(line)) return toks @@ -3732,6 +3785,21 @@ def classify( "a review wait whose condition can stay false forever", ), ("while true; do sleep 30; done", "deny", "the shape with no condition to become true at all"), + ( + "while ! gh pr checks 5 --watch; do sleep 30; done # the PR's checks", + "deny", + "an apostrophe in a trailing comment leaves the quoting unparseable, which must not hide the loop", + ), + ( + "while ! gh pr checks 5 --watch; do sleep 30; done # the PRs checks", + "deny", + "the same loop and comment with no apostrophe", + ), + ( + "timeout 600 bash -c 'until [ -f x ]; do sleep 30; done' # the PR's checks", + "allow", + "a bounded loop stays accepted beside an apostrophe in a trailing comment", + ), ( "bash -c 'until [ -f /tmp/done ]; do sleep 10; done'", "deny", From f5934be1beec665977a9f2a50c5b78f3023c2901 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 14:27:59 -0700 Subject: [PATCH 2/7] Read Quotes Literally on a Line Whose Quoting Does Not Parse Pairing each quote with the next one on the line hid commands bash runs, such as one inside nested quoting in a command substitution, where the old word split failed closed. A line the lexer cannot parse now keeps every quote as a literal character and splits out only the operator runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 78 ++++++------------- 1 file changed, 24 insertions(+), 54 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index a0d1007b7..9d9e2c75a 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -349,63 +349,23 @@ 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) +_LITERAL_QUOTE_TOKEN = re.compile( + f"[{re.escape(_PUNCTUATION_CHARS)}]+|[^ \\t\\r{re.escape(_PUNCTUATION_CHARS)}]+" +) -def _operator_tokens(text): - """Tokenize one line of text as `_shell_tokens` does, isolating operator runs, including where - its quoting cannot be parsed. An unterminated quote is read as a literal character there rather - than raising, since an apostrophe in a trailing `#` comment is the ordinary way to reach one. +def _operator_tokens(line): + """Tokenize one line as the primary path does, isolating operator runs. Where the line's own + quoting does not parse, every quote is read as a literal character instead, so no quote + pairing bash does not share can hide a command it runs, and only the operators are split out. """ - toks = [] - word = None - i = 0 - n = len(text) - while i < n: - c = text[i] - if c in _SHELL_OP_CHARS: - if word is not None: - toks.append(word) - word = None - j = i - while j < n and text[j] in _SHELL_OP_CHARS: - j += 1 - toks.append(text[i:j]) - i = j - continue - if c.isspace(): - if word is not None: - toks.append(word) - word = None - i += 1 - continue - word = word or "" - if c == "\\" and i + 1 < n: - word += text[i + 1] - i += 2 - elif c == "'" and "'" in text[i + 1 :]: - j = text.index("'", i + 1) - word += text[i + 1 : j] - i = j + 1 - elif c == '"': - j = i + 1 - quoted = "" - while j < n and text[j] != '"': - if text[j] == "\\" and j + 1 < n and text[j + 1] in '"\\': - j += 1 - quoted += text[j] - j += 1 - if j < n: - word += quoted - i = j + 1 - else: - word += c - i += 1 - else: - word += c - i += 1 - if word is not None: - toks.append(word) - return toks + try: + lex = shlex.shlex(line, posix=True, punctuation_chars=_PUNCTUATION_CHARS) + lex.whitespace_split = True + lex.commenters = "" + return list(lex) + except (ValueError, TypeError): + return _LITERAL_QUOTE_TOKEN.findall(line) def _shell_tokens(cmd): @@ -2328,6 +2288,11 @@ def classify( # --- Self-test --------------------------------------------------------------------------------------- _CASES = [ # (command, expected_decision, label) + ( + 'echo "$(echo "\'")"; gh issue comment 1 --repo stranger/x --body hi; echo "\'" # it\'s', + "deny", + "a line whose quoting does not parse reads each quote literally, so no pairing hides the write bash runs", + ), ( 'gh api graphql -f query=\'mutation($t:ID!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$t,body:"x"}){comment{id}}}\' -F t="PRRT_kwDODvuuzM6SFvx0" >/dev/null 2>&1 || true', "deny", @@ -3800,6 +3765,11 @@ def classify( "allow", "a bounded loop stays accepted beside an apostrophe in a trailing comment", ), + ( + 'echo "$(echo "\'")"; while true; do sleep 5; done; echo "\'" # it\'s', + "deny", + "a quote pairing bash does not share cannot hide the loop on a line whose quoting does not parse", + ), ( "bash -c 'until [ -f /tmp/done ]; do sleep 10; done'", "deny", From 485d9c4b9f4f46b67f3e2c3f1cc44f3e6b2cfb9d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 19:08:27 -0700 Subject: [PATCH 3/7] Test a Bounded Loop the Token Fallback Actually Reads The bounded case beside an apostrophe comment wrapped its loop in bash -c, which the fallback cannot see into, so it passed without the timeout bound being recognized. Pair an unbounded loop the fallback reads directly with the same loop under a timeout instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- host-setup/agent-safety/claude/gh-write-guard.py | 9 +++++++-- 1 file changed, 7 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 9d9e2c75a..2f4699b0a 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -3761,9 +3761,14 @@ def classify( "the same loop and comment with no apostrophe", ), ( - "timeout 600 bash -c 'until [ -f x ]; do sleep 30; done' # the PR's checks", + "until [ -f x ]; do sleep 30; done # the PR's checks", + "deny", + "an unbounded loop the fallback reads directly is still seen beside an apostrophe in a trailing comment", + ), + ( + "timeout 600 until [ -f x ]; do sleep 30; done # the PR's checks", "allow", - "a bounded loop stays accepted beside an apostrophe in a trailing comment", + "the same loop under a timeout bound stays accepted beside an apostrophe in a trailing comment", ), ( 'echo "$(echo "\'")"; while true; do sleep 5; done; echo "\'" # it\'s', From c2242775ddb464588448fb5cab3c67408e2c1ed9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 19:13:53 -0700 Subject: [PATCH 4/7] Strip Only a Trailing Comment When a Line's Quoting Does Not Parse Reading every quote literally split operator characters out of quoted arguments, so a cross-owner write carrying a semicolon in its body passed beside an apostrophe comment, and a heredoc marker inside an unclosed quote hid the loop after it. Cut the line at the comment bash sees instead, only where plain quotes are all it holds, and fall back to plain splitting otherwise, as the base did. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 72 +++++++++++++++---- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 2f4699b0a..2c751630e 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -349,23 +349,50 @@ 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) -_LITERAL_QUOTE_TOKEN = re.compile( - f"[{re.escape(_PUNCTUATION_CHARS)}]+|[^ \\t\\r{re.escape(_PUNCTUATION_CHARS)}]+" -) +_COMMENT_SCAN_BAIL = ("\\", "`", "$(", "${", "$'", '$"') + + +def _strip_trailing_comment(line): + """Return `line` cut at the `#` bash reads as a comment, or None where there is none or the line + holds quoting this scan does not model. Only plain quotes are tracked, so a line it accepts is + one whose quote boundaries it reads exactly as bash does. + """ + if any(s in line for s in _COMMENT_SCAN_BAIL): + return None + quote = None + for i, ch in enumerate(line): + if quote: + if ch == quote: + quote = None + elif ch in "'\"": + quote = ch + elif ch == "#" and (i == 0 or line[i - 1] in " \t" or line[i - 1] in _SHELL_OP_CHARS): + return line[:i] + return None def _operator_tokens(line): - """Tokenize one line as the primary path does, isolating operator runs. Where the line's own - quoting does not parse, every quote is read as a literal character instead, so no quote - pairing bash does not share can hide a command it runs, and only the operators are split out. + """Tokenize one line as the primary path does, isolating operator runs. A line that does not + parse only because of a trailing comment, an apostrophe in it being the usual case, is + tokenized without that comment. Any other line that does not parse falls back to plain + splitting, which keeps a quoted argument whole rather than cutting it at an operator character. """ + for text in (line, _strip_trailing_comment(line)): + if text is None: + continue + try: + lex = shlex.shlex(text, posix=True, punctuation_chars=_PUNCTUATION_CHARS) + lex.whitespace_split = True + lex.commenters = "" + return list(lex) + except TypeError: # punctuation_chars unsupported on old Python + break + except ValueError: + continue try: - lex = shlex.shlex(line, posix=True, punctuation_chars=_PUNCTUATION_CHARS) - lex.whitespace_split = True - lex.commenters = "" - return list(lex) - except (ValueError, TypeError): - return _LITERAL_QUOTE_TOKEN.findall(line) + return shlex.split(line, posix=True) + except ValueError: + return line.split() def _shell_tokens(cmd): @@ -2291,7 +2318,22 @@ def classify( ( 'echo "$(echo "\'")"; gh issue comment 1 --repo stranger/x --body hi; echo "\'" # it\'s', "deny", - "a line whose quoting does not parse reads each quote literally, so no pairing hides the write bash runs", + "a line whose quoting does not parse and is not comment-stripped still shows the write bash runs", + ), + ( + "gh issue comment 1 --body 'fixed; see log' --repo stranger/x # it's", + "deny", + "an operator inside a quoted argument stays in it beside an apostrophe in a trailing comment", + ), + ( + "gh issue comment 1 --body 'fixed (see log)' --repo stranger/x # it's", + "deny", + "parentheses inside a quoted argument stay in it beside an apostrophe in a trailing comment", + ), + ( + "gh api graphql -f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' -F t=\"$TID\" # it's", + "deny", + "a quoted mutation stays whole beside an apostrophe in a trailing comment", ), ( 'gh api graphql -f query=\'mutation($t:ID!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$t,body:"x"}){comment{id}}}\' -F t="PRRT_kwDODvuuzM6SFvx0" >/dev/null 2>&1 || true', @@ -3771,9 +3813,9 @@ def classify( "the same loop under a timeout bound stays accepted beside an apostrophe in a trailing comment", ), ( - 'echo "$(echo "\'")"; while true; do sleep 5; done; echo "\'" # it\'s', + "echo it's < Date: Sat, 26 Sep 2026 19:08:25 -0700 Subject: [PATCH 5/7] Strip Comments Over the Whole Command With Quote State Across Lines Scanning one line at a time started each line outside any quote, so a comment marker inside a quote an earlier line opened cut the command after the quote closed, and a write or loop there went unseen. Strip every comment over the whole command before parsing it, carrying the quote state across lines as bash does, and skip the strip where a heredoc is present. The heredoc opener keeps the base tokenizer with no comment stripping. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 115 +++++++++++------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 2c751630e..4ca6bbcef 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -352,72 +352,84 @@ def _current_push_branch(cwd): _COMMENT_SCAN_BAIL = ("\\", "`", "$(", "${", "$'", '$"') -def _strip_trailing_comment(line): - """Return `line` cut at the `#` bash reads as a comment, or None where there is none or the line - holds quoting this scan does not model. Only plain quotes are tracked, so a line it accepts is - one whose quote boundaries it reads exactly as bash does. +def _strip_comments(cmd): + """Return `cmd` with every comment bash reads cut away, or None where it holds none or holds + quoting or a heredoc 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 line for s in _COMMENT_SCAN_BAIL): + if "<<" in cmd or any(s in cmd for s in _COMMENT_SCAN_BAIL): return None + out = [] quote = None - for i, ch in enumerate(line): - if quote: + 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 line[i - 1] in " \t" or line[i - 1] in _SHELL_OP_CHARS): - return line[:i] - return None + 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 _operator_lex(text): + """Tokenize `text`, isolating operator runs, and raise where its quoting does not parse.""" + lex = shlex.shlex(text, posix=True, punctuation_chars=_PUNCTUATION_CHARS) + lex.whitespace_split = True + # `shlex.shlex`'s own default keeps `#` as a comment starter, unlike `shlex.split()`, which explicitly clears it, and confirmed live to otherwise fuse `git fetch origin # x\ngit reset --hard` into one invocation, hiding the second command from every tokenizer-based rule. + # Cleared unconditionally: a truncated command is a far worse failure than an ordinary `#` becoming literal trailing argv words instead. + lex.commenters = "" + lex.whitespace = lex.whitespace.replace( + "\n", "" + ) # A newline is an operator above rather than a gap between words. + return list(lex) def _operator_tokens(line): """Tokenize one line as the primary path does, isolating operator runs. A line that does not - parse only because of a trailing comment, an apostrophe in it being the usual case, is - tokenized without that comment. Any other line that does not parse falls back to plain - splitting, which keeps a quoted argument whole rather than cutting it at an operator character. + parse falls back to plain splitting, which keeps a quoted argument whole rather than cutting it + at an operator character. """ - for text in (line, _strip_trailing_comment(line)): - if text is None: - continue - try: - lex = shlex.shlex(text, posix=True, punctuation_chars=_PUNCTUATION_CHARS) - lex.whitespace_split = True - lex.commenters = "" - return list(lex) - except TypeError: # punctuation_chars unsupported on old Python - break - except ValueError: - continue + try: + return _operator_lex(line) + except (ValueError, TypeError): # bad quoting, or punctuation_chars unsupported on old Python + pass try: return shlex.split(line, posix=True) except ValueError: return line.split() -def _shell_tokens(cmd): +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. Degrades gracefully if the - quoting cannot be parsed. + 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. Degrades gracefully if the quoting still cannot be parsed. """ try: - lex = shlex.shlex(cmd, posix=True, punctuation_chars=_PUNCTUATION_CHARS) - lex.whitespace_split = True - # `shlex.shlex`'s own default keeps `#` as a comment starter, unlike `shlex.split()`, which explicitly clears it, and confirmed live to otherwise fuse `git fetch origin # x\ngit reset --hard` into one invocation, hiding the second command from every tokenizer-based rule. - # Cleared unconditionally: a truncated command is a far worse failure than an ordinary `#` becoming literal trailing argv words instead. - lex.commenters = "" - lex.whitespace = lex.whitespace.replace( - "\n", "" - ) # A newline is an operator above rather than a gap between words. - return list(lex) + return _operator_lex(cmd) except (ValueError, TypeError): # bad quoting, or punctuation_chars unsupported on old Python - toks = [] - for i, line in enumerate(cmd.split("\n")): - if i: - toks.append("\n") - toks.extend(_operator_tokens(line)) - return toks + pass + stripped = _strip_comments(cmd) if strip_comments else None + if stripped is not None: + try: + return _operator_lex(stripped) + 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 _is_shell_op(tok): @@ -2115,7 +2127,7 @@ def _heredoc_opener(line): 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. """ - toks = _shell_tokens(line) + toks = _shell_tokens(line, strip_comments=False) if any("((" in t for t in toks): return None for k, tok in enumerate(toks): @@ -2320,6 +2332,11 @@ def classify( "deny", "a line whose quoting does not parse and is not comment-stripped still shows the write bash runs", ), + ( + 'echo "start\nx # y"; gh issue comment 1 --repo stranger/x --body hi\necho done # it\'s', + "deny", + "a `#` inside a quote an earlier line opened is text, so the write after the quote closes is seen", + ), ( "gh issue comment 1 --body 'fixed; see log' --repo stranger/x # it's", "deny", @@ -3812,6 +3829,16 @@ def classify( "allow", "the same loop under a timeout bound stays accepted beside an apostrophe in a trailing comment", ), + ( + 'echo "a\n# b"\nwhile true; do sleep 5; done # the PR\'s checks', + "deny", + "a quote carried across lines still leaves the comment after it stripped and the loop seen", + ), + ( + 'echo "a\n< Date: Sat, 26 Sep 2026 19:13:10 -0700 Subject: [PATCH 6/7] Skip the Comment Strip Inside Arithmetic and Extglob Patterns A comment marker inside arithmetic or an extglob pattern is text to bash, so cutting the line there beside an apostrophe comment dropped a write bash still runs after it. Leave any command holding either construct to plain splitting, as the base did. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 4ca6bbcef..56ee772be 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -349,15 +349,17 @@ 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 = ("\\", "`", "$(", "${", "$'", '$"') +_COMMENT_SCAN_BAIL = ("\\", "`", "$(", "${", "$'", '$"', "<<", "((") +_EXTGLOB_OPEN = re.compile(r"[@?*+!]\(") def _strip_comments(cmd): """Return `cmd` with every comment bash reads cut away, or None where it holds none or holds - quoting or a heredoc this scan does not model. Only plain quotes are tracked, and their state + 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 "<<" in cmd or any(s in cmd for s in _COMMENT_SCAN_BAIL): + if any(s in cmd for s in _COMMENT_SCAN_BAIL) or _EXTGLOB_OPEN.search(cmd): return None out = [] quote = None @@ -2337,6 +2339,16 @@ def classify( "deny", "a `#` inside a quote an earlier line opened is text, so the write after the quote closes is seen", ), + ( + "(( 1 #x )); gh issue comment 1 --repo stranger/x --body hi # it's", + "deny", + "a `#` inside arithmetic is text, so the write after it is seen beside an apostrophe comment", + ), + ( + "[[ $x == @(a|#b) ]]; gh issue comment 1 --repo stranger/x --body hi # it's", + "deny", + "a `#` inside an extglob pattern is text, so the write after it is seen beside an apostrophe comment", + ), ( "gh issue comment 1 --body 'fixed; see log' --repo stranger/x # it's", "deny", From 0341099a1b56f74b19844f3991c038d37839c3ef Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 26 Sep 2026 19:20:45 -0700 Subject: [PATCH 7/7] Keep the Base Tokens Beside the Comment-Stripped Ones The comment strip decides where a comment starts from a list of bash contexts, and each review pass found another context where a comment marker is text, such as a regex after =~, that hid a write the base fallback saw. Append the base fallback's tokens after the stripped ones, so the strip can reveal a command the base missed but never hide one it saw. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-safety/claude/gh-write-guard.py | 24 +++++++++++++++++-- 1 file changed, 22 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 56ee772be..3cdf2255b 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -409,12 +409,27 @@ 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. Degrades gracefully if the quoting still cannot be parsed. + 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. """ try: return _operator_lex(cmd) @@ -423,7 +438,7 @@ def _shell_tokens(cmd, strip_comments=True): stripped = _strip_comments(cmd) if strip_comments else None if stripped is not None: try: - return _operator_lex(stripped) + return [*_operator_lex(stripped), "\n", *_base_tokens(cmd)] except (ValueError, TypeError): pass toks = [] @@ -2349,6 +2364,11 @@ def classify( "deny", "a `#` inside an extglob pattern is text, so the write after it is seen beside an apostrophe comment", ), + ( + "[[ a =~ a|#b ]]; gh issue comment 1 --repo stranger/x --body hi # it's", + "deny", + "a `#` inside a regex is text, so the write after it is seen beside an apostrophe comment", + ), ( "gh issue comment 1 --body 'fixed; see log' --repo stranger/x # it's", "deny",