From 5a5fdc9bb04dc3d26c7f5034d79191decdd1eb36 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 22 Oct 2026 13:29:44 -0700 Subject: [PATCH 1/2] Keep Escaped Backslash Pairs Before a Line Break in the Write Guard A shell running a kept quoted span reads each escaped backslash pair before a line break as one literal backslash, so the word keeps it. Every reading dropped the whole run, which read a push of a word ending in a backslash as a push of the word without it. The readings now keep the pairs, parted from the line break by a space so no reading holds a backslash-newline to read again. Also correct the README and the _literal_span_kept docstring, which understated the word test and the names test, and read as if every kept span is read as its own scan places it. Refs #3358 Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/README.md | 13 +++-- .../agent-safety/claude/gh-write-guard.py | 49 +++++++++++++++---- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index e9f398401..7e27b3005 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -185,11 +185,14 @@ text says, because the harm it covers was never in the text. text in a `$(...)` substitution. It is also one ending a quoted heredoc's data line, unless the delimiter line follows. It is not one inside `$'...'`, or single quotes within double quotes, a backquote, or `${...}`. A Markdown hard line break in a quoted heredoc or a single-quoted body - is one. The single-quoted part must be its word's only quoted part. Where the text names git or - gh, it must also hold no further quoting. Each backslash-newline in it must then sit in its - command text or end a comment there. The command is then read again as a shell running that text reads it, - joining the lines except where one ends a comment. It is also read with each one joined and with - each one a line break, and denied where any reading is. Nor does either read a single unquoted + is one. The single-quoted part must be its word's only quoted, escaped, or expanded part, with no + escaped word break just before it. Where the text names git, gh, or a push, it must also hold no + further quoting. Those names are read in the text as written, with its lines joined, and with + each one a line break. Each backslash-newline in that text must then sit in its command text or + end a comment there. The command is then read again as a shell running that text reads it, + joining the lines except where one ends a comment. It is also read with each one joined and with each one a line break, and denied where + any reading is. Every reading keeps each escaped backslash pair before a line break, which the + shell reads as one literal backslash. Nor does either read a single unquoted backslash-newline between a command separator such as `&&` or `|` and a non-operator character, which splits no word. Two comments whose apostrophes pair up as one quoted span are read as the shell reads them, as comments. The diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 9df355798..210f3d040 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -4239,12 +4239,13 @@ def command(i, closer): def _literal_span_kept(cmd, start, stop, word, inner): - """True where a shell running the `literal` text `cmd[start:stop]` reads each backslash-newline in it as `inner` places it. + """True where the readings of the `literal` text `cmd[start:stop]` show what a shell running it runs. - A span in a word with any other quoted or expanded part is never kept, since the shell runs the whole word. - A span naming no git or gh, however its backslash-newlines are read, is kept, since its readings show what it runs. + A span in a word with any other quoted, escaped, or expanded part is never kept, since the shell runs the whole word. + Nor is one with an escaped word break just before it. + A span naming no git, gh, or push, as written, joined, or with line breaks, is kept whatever its own scan found. Any other span is kept only where its own scan finished and it holds no quoting a further shell would unwrap. - Each backslash-newline in it must then sit in command text or end a comment there. + Each backslash-newline in it must then sit in command text or end a comment there, as `inner` places it. """ if word: head = cmd.rfind(" ", 0, start - 1) + 1 @@ -4280,7 +4281,8 @@ def _line_break_reading(cmd): One reading takes each kept backslash-newline as the span's own scan places it. A shell running the span reads one ending a comment there as a line break, and joins any other. The other readings join each one, or read each one as a line break. - An escaped backslash before a kept newline is a line break in all of them. + Each escaped backslash pair before a kept newline stays in all of them, read as one literal backslash. + A space parts those pairs from a line break, so no reading holds a backslash-newline to read again. A scan that does not finish leaves every backslash-newline in a context the shell joins. """ try: @@ -4304,9 +4306,13 @@ def _line_break_reading(cmd): spans[start, stop] = (inner or {}) if kept else None inner = spans[start, stop] if inner is not None: - joined = cmd[m.start() : nl - 1] if odd else "\n" + pairs = cmd[m.start() : nl - 1 if odd else nl] + broken = f"{pairs} \n" if pairs else "\n" + joined = pairs if odd else broken ends_comment = inner.get(nl - start) == "comment" - edits.append((m.start(), nl + 1, joined, "\n" if ends_comment else joined)) + edits.append( + (m.start(), nl + 1, joined, broken if ends_comment else joined, broken) + ) continue kind = None if not odd: @@ -4329,10 +4335,10 @@ def _line_break_reading(cmd): if not edits: return False, () readings = [] - for pick in (3, 2, None): + for pick in (3, 2, 4): out, at = [], 0 for edit in edits: - out.append(cmd[at : edit[0]] + ("\n" if pick is None else edit[pick])) + out.append(cmd[at : edit[0]] + edit[pick]) at = edit[1] readings.append("".join(out) + cmd[at:]) return False, tuple(dict.fromkeys(readings)) @@ -9325,6 +9331,31 @@ def _nested_wrapper(body, depth): "deny", "an escaped backslash before a newline in a single-quoted body is a line break to the inner shell", ), + ( + "bash -c 'git push origin main\\\\\necho hi'", + "allow", + "an escaped backslash pair before a line break in a shell body stays a literal backslash on the word", + ), + ( + "bash <<'EOF'\ngit push origin main\\\\\necho hi\nEOF", + "allow", + "a quoted heredoc fed to a shell keeps the literal backslash an escaped pair leaves before a line break", + ), + ( + "bash -c 'git push origin HEAD:main\\\\\\\n'", + "allow", + "an escaped pair before a backslash-newline in a shell body stays a literal backslash in every reading", + ), + ( + "bash -c 'gh\\\\\npr merge 5 --admin'", + "allow", + "an escaped backslash pair after gh in a shell body names a command other than gh", + ), + ( + "bash -c 'git push origin feature/x\\\\\ngit push origin main'", + "deny", + "control: the line after an escaped backslash pair in a shell body is still read", + ), ( "bash -c 'true # x\\\ngh pr merge 5 --ad\\\nmin'", "deny", From b982f88f47095e65ad4a75bfd421bfeb31f8b78e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 22 Oct 2026 13:38:30 -0700 Subject: [PATCH 2/2] Correct the Reading Docstring and Cover a Pair Ending a Comment The docstring said no reading holds a backslash-newline, but a joined odd run followed by an empty line leaves its pairs before a newline, which classify reads again and denies as the shell would. State only that the break a reading adds is not read again, and add controls for an escaped pair ending a comment in a shell body. Refs #3358 Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/claude/gh-write-guard.py | 12 +++++++++++- 1 file changed, 11 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 210f3d040..99dfb7f68 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -4282,7 +4282,7 @@ def _line_break_reading(cmd): A shell running the span reads one ending a comment there as a line break, and joins any other. The other readings join each one, or read each one as a line break. Each escaped backslash pair before a kept newline stays in all of them, read as one literal backslash. - A space parts those pairs from a line break, so no reading holds a backslash-newline to read again. + A space parts those pairs from each line break a reading puts after them, so that break is not read again. A scan that does not finish leaves every backslash-newline in a context the shell joins. """ try: @@ -9356,6 +9356,16 @@ def _nested_wrapper(body, depth): "deny", "control: the line after an escaped backslash pair in a shell body is still read", ), + ( + "bash -c 'true # x\\\\\ngit push origin main'", + "deny", + "control: an escaped backslash pair ending a comment in a shell body leaves the next line read", + ), + ( + "bash -c 'true # x\\\\\\\ngit push origin main'", + "deny", + "control: an escaped pair before a comment-ending backslash-newline leaves the next line read", + ), ( "bash -c 'true # x\\\ngh pr merge 5 --ad\\\nmin'", "deny",