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..99dfb7f68 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 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: @@ -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,41 @@ 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\\\\\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",