diff --git a/.claude/skills/agentic-delivery/scripts/flow_alarm.py b/.claude/skills/agentic-delivery/scripts/flow_alarm.py index f75dd124a..af255da45 100644 --- a/.claude/skills/agentic-delivery/scripts/flow_alarm.py +++ b/.claude/skills/agentic-delivery/scripts/flow_alarm.py @@ -5,7 +5,35 @@ flow_alarm.py PreToolUse(Bash) hook (stdin JSON): STOP STARTING. Denies (exit 2) `gh pr create` while ready-but-unmerged PRs >= PERUN_STOP_STARTING_READY (default 5): waiting work is queue, and a new PR only lengthens it. Override for priority work: `# priority: ` in the command. - Non-PR commands, a failed `gh`, unparseable input -> allow (fail open). + Gated by the command's text: one without `gh pr create` in it (any case; also read with its + quotes and backslashes removed) passes. One with it, in code, in a comment or in a heredoc + body, is denied unless every command in it is a known text tool (echo, grep, git log, cat ...; + see `gated`), so a grep, an echo, a commit message or an issue body that only names it is + allowed. Anything not known to be text-only keeps the deny: an unknown program or wrapper + (timeout, env, sudo, `bash -c`, `| sh`, `tee`, a path such as `./echo`: the command word must + be a listed name exactly), a variable set before a command unless its name is inert + (GIT_AUTHOR_*, GIT_COMMITTER_*, LANG, LC_*, TZ, NO_COLOR: an editor, a pager, a config entry + or an external diff may run a command), a git subcommand outside log, show, diff, grep, + status, blame, commit and add (`rebase --exec`, `bisect run`, `apply`, `-p`, `--config-env`, + ...), an option that runs or stores a command or writes a file (`git -c`, `git grep -O`, `git + log --output`, `gh alias`, `gh config`, `rg --pre`, `rg --hostname-bin`), `gh api` and `gh + extension`, a `gh pr` followed anywhere, flags or not, by `create`, `new` (its alias) or an + expansion, a `$` or `{` in a git word (an expansion may build such an option: `git log $o`; a + `commit -m` message may expand), a command that writes a file, a `$(...)` that runs it, an + unquoted backtick, `$'..'` quoting, `printf -v`, `${NAME:=..}`, a quote that does not close, a + `#` or `<<` that is not provably a comment or a heredoc, and a heredoc beside a comment. A + failed `gh`, a timeout or unparseable hook JSON allows (fail open). A flow alarm, not a + security boundary. Still passing: `$(which gh) pr create`, `${GH} pr create`, `gh${IFS}pr + create`, `gh $'pr' create` and `gh pr cre{a,}te` (the text is not in the command), a script + written by an editor tool and run later, a `gh` alias or extension that is already installed, + and another allowed tool's own exec flag that `gated` does not list. Over-denied (the safe + direction): a `#` that cannot be proven to start a comment, a single-quoted backtick or `$(`, + a `(`, backtick or open quote after a heredoc marker, a backslash in a delimiter word, a `)` + in a heredoc body inside `$(`, a `$`, `*`, `?`, `[` or `{` in the first two words of `gh` (its + own `{owner}` placeholder) or after `gh pr`, a `$` or `{` in a git word (a `commit -m` message + may) or an rg option, a mention-only pipeline through a command outside SAFE, a non-read git + subcommand (`push`) beside a mention, even one only in a comment, a heredoc written to a file + beside a mention, an assignment of another variable, `printf -v`, `${x:=..}`. flow_alarm.py symptom [--hours H] [--red-since TS] ONE alarm instead of a monitor per invariant. Prints `ALARM flow: N ready PR(s) and nothing merged in Xh` when ready PRs exist and the last merge is older than H (default 2), and @@ -22,6 +50,7 @@ import json import os import re +import shlex import statistics import subprocess import sys @@ -29,6 +58,25 @@ from datetime import datetime, timezone OK = {"SUCCESS", "NEUTRAL", "SKIPPED"} +MENTION = re.compile(r"\bgh\s+pr\s+create\b", re.I) # the command's text, in any case; a command without it is never gated +PUNCT = ";()<>|&\n`" # characters shlex splits at in gated(); a run of them is one operator token +REDIR = {"<", ">", ">>", "<<", "<<-", "<<<", ">&", "<&", "&>", "&>>", ">|", "<>"} # redirects: skipped with their target word +# splits a run of operator characters: `;>` is `;` then `>` +OPS = re.compile(r"&>>|<<<|;;&|&&|\|\||\|&|;;|;&|>>|<<|>&|<&|&>|>\||<>|.", re.S) +DYN = re.compile(r"[$*?[{]").search # a character a shell may expand into other words +# a git option that runs a command: -c, -O (also bundled: -nOcmd), --open-files-in-pager, a `!` alias +GIT_EXEC = re.compile(r"-(c|[^-]*O)|--op|!").match +# <()'\"\\]+))(?=[\s;&|<>()]|$)") +COMMENT = re.compile(r"(?` or `\n>` is several operators: each redirects or ends the command + op = o if o in REDIR else "" + if not op: + segs.append(seg) + seg = [] + elif ("$(" in t or "`" in t) and _subst_fails(t): + return True + elif op: + wr = wr or (">" in op and t != "/dev/null" and not (op == ">&" and not t.strip("0123456789-"))) + op = "" + else: + seg.append(t) + return wr or any(_cmd_fails(s) for s in segs + [seg]) + + +def gated(cmd, strip=True): + """True when the Bash command `cmd` is held at the stop-starting cap: it holds the text `gh pr create` (any case) and is not + known to be text-only. Fail closed: a step that drops text before parsing keeps it when the input is ambiguous (keep = deny). + The text counts wherever it sits, in code, in a comment or in a heredoc body. In order: + 1. without the text -> False; the text is also looked for with quotes and backslashes removed and backslash-newline joined, + so a quoted, escaped or continued form reaches the parser; + 2. line by line: a newline after an odd run of backslashes is joined, except after a line holding a `#` that follows a blank + (bash ends a comment at the newline); a `#` that follows a blank and is provably a comment (`_code`) drops the rest of the + line; + 3. heredoc bodies (`< True, and so is a body holding `$(` or a backtick under an unquoted delimiter + (the shell expands it), an operator line that opens a `(`, a backtick or a quote after the marker (the shell reads it + across the body lines, so the body starts later), and a `)` in the body or on the operator line after a `(` (it may close + a `$(` before the delimiter line in older shells). Any other `<<` keeps its body in place, where it is read as commands. + A heredoc found beside a comment dropped in step 2 (a `#` outside every body; a `#` in a body is data) is not stripped: + the command is read again with `strip=False`, every body in place, so each body line and its delimiter line must pass as + commands; + 4. text holding `$'` -> True (ANSI-C quoting: shlex and a shell may disagree where the quote ends), and so is an expansion + that assigns (`${NAME:=..}`: a tool may run the variable as an editor or a pager). The rest is split with shlex + (`_tokens`): `;`, `&&`, `|`, `&`, parentheses, backticks, redirections and newlines separate commands, `#` is not a + comment; a quote that does not close -> True; + 5. a run of operator characters is split into operators (`;>` is `;` then `>`). A redirect operator (`<`, `>`, `>>`, `<<`, + `<<<`, `>&`, `<&`, `&>`, `&>>`, `>|`, `<>`) and its target word are skipped; any other operator (`;`, `(`, `&&`) ends the + command, so the word after it is a command word. An output redirect to anything but /dev/null or a file descriptor is a + file write; + 6. every `$(...)` and backtick body inside a quoted word must pass 4-8 itself; a nested `$(` or a lone backtick -> True, and + so is an unquoted backtick (it runs its body, and its output may run as the command); + 7. a command passes when, after shell keywords and leading `NAME=value` words, it is empty or its first word is exactly a + name in SAFE (text tools that run none of their arguments; `./echo` and `/bin/echo` are other programs). An assignment + passes only for an inert name (INERT: GIT_AUTHOR_*, GIT_COMMITTER_*, LANG, LC_*, TZ, NO_COLOR); any other variable, bare + or before a command, fails: an editor, a pager, a config entry or an external diff may run it. Still failing: `gh` with + an `alias`, `config`, `extension` or `api` word, a `$`, `*`, `?`, `[` or `{` in its first two words, or a `pr` followed, + at any position (flags may come between), by `create`, its alias `new` or a word that holds such a character; `git` with + a word that starts `-c`, `--op`, `--output` or `!`, is a short-option bundle holding `O` (`-nOcmd`: a pager that runs a + command) or holds `$` or `{` (an expansion may build an option: `git log $o` for `--output=gc`; only the word after + `commit -m` or `-am` may expand), or a subcommand outside GIT_READ (after any `-C DIR`): `rebase --exec`, `bisect run`, + `submodule foreach`, `apply`, `-p`, `--config-env` and the rest fail, in every `git` segment, because one may write a + config that a later git runs, even from a segment without the text; `printf` whose first word is an option (`printf -v + NAME` assigns a variable that a tool may run) or holds `$`, `{` or a backtick; `rg` with a word starting `--pre` or + `--hostname`, or an option holding `$` or `{`. Not in SAFE: sed, awk, find, xargs, sort, tee, uniq (it writes its second + argument), env, timeout, nice, sudo, exec, command, time, nohup, eval, source, `.`, watch, ssh, shells and interpreters; + 8. any output redirect to a file (the whole command holds the text, also when only a comment or a heredoc body does), or any + failing command -> True. + Passing though it may run the command: `$(which gh) pr create`, `${GH} pr create`, `gh${IFS}pr create`, `gh $'pr' create`, + `gh pr cre{a,}te` (no `gh pr create` text), a script written first by an editor tool, or by a command without the text, and + run later, a `gh` alias or extension that is already installed (it runs under its own name), another allowed tool's own exec + flag that is not listed. `$GH pr create` holds the text, and `$GH` is not in SAFE. + Denied though it only names it: a `#` or `<<` that cannot be proven to be a comment or a heredoc, a comment beside a heredoc, + a backtick, `$'`, a non-read git subcommand beside the text (`git push && echo ...`), also when the text is only in a comment, + a file written beside the text, also when the text is only in a heredoc body (`cat > notes.md <<'EOF'`), an assignment of a + variable that is not inert, `printf -v`, `${x:=..}`, a `$` or `{` in a git word (`git log "$base"..HEAD`, `-C "$dir"`), a `)` + in a heredoc body inside `$(`, a `(` after a heredoc marker, a `{owner}`-style word in `gh`, an expansion or the word `create` + or `new` after `gh pr`, `gh api`, a path as the command word (`/bin/echo`), a pipeline through a command outside SAFE. Pure: no + I/O.""" + if not _holds(cmd): + return False + lines, kept, hd, cmt, i = cmd.split("\n"), [], False, False, 0 + while i < len(lines): + line, i = lines[i], i + 1 + # an odd run of backslashes ends the line: a continuation + while (len(line) - len(line.rstrip("\\"))) % 2 and i < len(lines): + if COMMENT.search(line): # bash ends a comment at the newline, so this may be no continuation: keep the newline + line = line[:-1] + break + line, i = line[:-1] + lines[i], i + 1 + for m in COMMENT.finditer(line): # the first `#` that provably starts a comment ends the line + if _code("\n".join(kept + [line[:m.start()]])): + line, cmt = line[:m.start()], True + break + kept.append(line) + for m in HEREDOC.finditer(line) if strip else (): + pre = "\n".join(kept[:-1] + [line[:m.start()]]) + if not _code(pre): + continue # may sit in a quote or a comment: not a heredoc, its body stays and is read as commands + dash, sq, dq, bare = m.groups() + word = sq or dq or bare + end = next((j for j in range(i, len(lines)) if (lines[j].lstrip("\t") if dash else lines[j]) == word), None) + body, rest = lines[i:end], line[m.end():] + # no delimiter line; an expanded body; an operator line that opens a `(`, a backtick or a quote the shell may close + # on a later line (so the body starts later); or a `)` after a `(` that may close a `$(` before the delimiter line + # (older shells) + if (end is None or (not (sq or dq) and any("$(" in b or "`" in b for b in body)) or "(" in rest or not _code(rest) + or ("(" in pre and ")" in "".join(body + [rest]))): + return True + hd, i = True, end + 1 + # a heredoc beside a comment: read it again with every body in place, as commands + return gated(cmd, False) if hd and cmt else _fails("\n".join(kept)) + + def hook(): try: data = json.load(sys.stdin) cmd = (data.get("tool_input") or {}).get("command") or "" - if data.get("tool_name", "Bash") != "Bash" or not re.search(r"\bgh\s+pr\s+create\b", cmd) or "# priority:" in cmd: + if data.get("tool_name", "Bash") != "Bash" or not gated(cmd) or "# priority:" in cmd: return 0 n, cap = open_ready(10), int(os.environ.get("PERUN_STOP_STARTING_READY", "5")) except subprocess.TimeoutExpired: diff --git a/changelog.d/flow-alarm-command-word.md b/changelog.d/flow-alarm-command-word.md new file mode 100644 index 000000000..19a98af02 --- /dev/null +++ b/changelog.d/flow-alarm-command-word.md @@ -0,0 +1,11 @@ +### Fixed +- The stop-starting hook in `.claude/skills/agentic-delivery/scripts/flow_alarm.py` denied any Bash command whose raw text matched `gh pr create`, so at the cap a `grep`, an `echo`, a commit message or an issue body that only named the command was blocked, while a quoted or escaped form such as `gh pr "create"` or `g\h pr create` was not matched at all. A command now counts as naming it when its text, in any case and also read with quotes and backslashes removed and backslash-newline joined, contains `gh pr create`, wherever it sits (in code, in a comment or in a heredoc body). A command that names it is denied (exit 2 at the cap) unless every command in it is a known text tool (echo, printf, cat, grep, rg, head, tail, git and similar, and `gh` outside `pr create`, `pr new` and `api`). A failed `gh`, a timeout and unparseable hook JSON still allow, and `# priority: ` still overrides. +- Every step that drops text before parsing now keeps the text when the input is ambiguous (keep means deny). A `#` is dropped as a comment, and a heredoc body as data, only when the text before it is provably shell code: no open quote, `$'..'`, `${..}`, `$[..]`, `((..))`, backtick or earlier comment, and the one open quote accepted is the `"$(` of a heredoc commit message. A backslash-newline is joined only where a shell joins it, not at the end of a comment. A heredoc needs one plain or fully quoted delimiter word without a backslash, and a delimiter line; a command with no delimiter line is denied, and so is one whose body holds `$(` or a backtick under an unquoted delimiter, whose operator line opens a `(`, a backtick or a quote after the marker (the shell reads it across the body lines), or whose body or operator line holds `)` inside a `$(` (an older shell can end the substitution there). A heredoc beside a comment is not stripped at all: the command is read again with every body in place, so each body line and its delimiter line must pass as commands (a `#` inside a body is data and does not count as the comment). +- A command that holds the text may use `git` only for the log, show, diff, grep, status, blame, commit and add subcommands, in every `git` segment of it, so `git rebase --exec`, `git bisect run`, `git submodule foreach`, `git apply` (a patch can write a config that a later `git` runs), `-c alias.*`, `git grep -O` (also bundled, as in `-nOcmd`), `--output` (it writes a file), `-p`, `--config-env` and any option before the subcommand other than `-C DIR` deny, and so does a `git` word that holds `$` or `{` (an expansion can build an option: `o=--output=gc` then `git log ... $o` writes a config that a later `git status` runs), except the message after `commit -m` or `-am`. These rules, and the file-write rule below, read the whole command, so a mention that sits only in a comment or a heredoc body counts as the text: `git rebase --exec '...' # gh pr create` is denied. +- `gh` is a text tool only outside `pr create`: a `pr` followed anywhere by `create`, its alias `new` or an expansion (`gh pr -R o/r create`, `gh pr --repo=o/r create`, `gh pr new --fill`, `gh -R o/r pr new`), and `gh` with an `api` word (it can open a pull request), an `alias`, `config` or `extension` word, or a `$`, `*`, `?`, `[` or `{` among its first two words, deny. So do `rg --pre` and `rg --hostname-bin`, a `gh` word that spells `create` through an expansion (`create$x`, `create{,}`, `create*`), an unquoted backtick and `$'..'` quoting. The command word must be a listed name exactly, so `./echo` and `/bin/echo` are other programs and deny. +- A variable set before a command passes only when its name is inert: `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_AUTHOR_DATE`, `GIT_COMMITTER_NAME`, `GIT_COMMITTER_EMAIL`, `GIT_COMMITTER_DATE`, `LANG`, `LC_*`, `TZ` and `NO_COLOR`. Any other name, such as `GIT_EXTERNAL_DIFF`, `GIT_EDITOR`, `GIT_CONFIG_*` or `PAGER`, is denied, bare or before a command, because an editor, a pager, a config entry or an external diff makes a tool run its value. `printf -v` (any `printf` whose first word is an option or expands) and an expansion that assigns (`${NAME:=..}`) deny for the same reason. +- Operators are read one at a time, so a redirect that follows a newline or `;` skips its own target and no longer hands the next word to the allowlist as a command. A command that names the text and writes a file (an output redirect to anything but `/dev/null` or a file descriptor, including `| cat > file`) is denied, and so is a file written beside a heredoc body that holds the text. `tee` and `uniq` are not on the allowlist. +- Still passing, and named in the hook docstring: `$(which gh) pr create`, `${GH} pr create`, `gh${IFS}pr create`, `gh $'pr' create` and `gh pr cre{a,}te` (the text is not in the command), a script written by an editor tool and run later, a `gh` alias or extension that is already installed (it runs under its own name), and another allowed tool's own exec flag that the hook does not list. Over-denied, the safe direction: a `#` that cannot be proven to start a comment (for example a quoted `#` before a heredoc), a single-quoted backtick or `$(`, a `(`, backtick or open quote after a heredoc marker, a backslash in a delimiter word, a `)` in a heredoc body inside `$(`, a `gh` word with `$`, `*`, `?`, `[` or `{` among its first two (its own `{owner}` placeholder), a mention-only pipeline through a command outside the allowlist, a non-read `git` subcommand such as `push` beside a mention (also one only in a comment), a heredoc written to a file beside a mention, a comment beside a heredoc, an assignment of a variable that is not inert, `printf -v`, `${x:=..}`, a `$` or `{` in a `git` word other than a `commit -m` message (`git log "$base"..HEAD`, `git -C "$dir" log`), and a command word that is a path. This is a flow alarm, not a security boundary. + +### Changed +- Tests: `python3 scripts/test_flow_alarm.py` goes from 10 to 22 tests. The new `Gate` class checks 35 commands that only name the command (exit 0 at the cap), 48 shapes that run it and that the old substring test denied on the raw text (exit 2), 125 deny probes (exit 2) that run it, write it where a later command runs it, hide it from a step that drops text before parsing, or spell it in another case, and 26 commands that only name it and are denied anyway (the safe direction). Further tests pin the git subcommand rule, the inert-variable rule, the file-write rule, the residuals above that still pass (5 shapes) and the unparseable-text case. diff --git a/scripts/test_flow_alarm.py b/scripts/test_flow_alarm.py index 0f2a3fd25..1e386a38d 100644 --- a/scripts/test_flow_alarm.py +++ b/scripts/test_flow_alarm.py @@ -1,11 +1,15 @@ """flow_alarm.py: stop-starting hook, one symptom alarm, value-stream split.""" +import importlib.util +import io import json import os import pathlib +import re import subprocess import sys import tempfile import unittest +from unittest import mock SCRIPT = pathlib.Path(__file__).resolve().parent.parent / ".claude/skills/agentic-delivery/scripts/flow_alarm.py" GREEN = {"isDraft": False, "statusCheckRollup": [{"conclusion": "SUCCESS"}]} @@ -99,5 +103,272 @@ def test_split_bad_input_exit_2(self): self.assertEqual(run(["bogus"]).returncode, 2) +class Gate(unittest.TestCase): + """At the cap hook() denies a command that holds `gh pr create` (any case, also with its quotes and backslashes removed, in + code, a comment or a heredoc body) unless every command in it is a known text tool. MENTION only names it. RUN runs it in a + shape the old substring test (`OLD`, on the raw text) denied. PROBE runs or hides it in a shape that fooled a looser gate. + RESIDUAL runs it and still passes (a documented gap). OVERDENY only names it and is denied anyway (the safe direction). The + module's own pattern is `self.fa.MENTION`.""" + CMD = "gh pr create" + OLD = re.compile(r"\bgh\s+pr\s+create\b") # the substring test this gate replaced + MENTION = [f"grep -rn '{CMD}' docs/", f"git log --grep '{CMD}'", f"echo '{CMD}'", f"git commit -m 'docs: explain {CMD}'", + "grep 'gh pr creat[e]'", + f"gh issue create --title x --body \"$(cat <<'EOF'\nThe agent ran {CMD} twice and was blocked.\nEOF\n)\"", + f"git commit -m \"$(cat <<'EOF'\nit's a \"quoted\" note on {CMD}\nEOF\n)\"", + f"gh issue create --title t --body-file - <<'EOF'\nUse {CMD} when ready\nEOF", + f"gh pr comment 5 --body \"the {CMD} step\"", f"cd docs && grep -rn '{CMD}' . | head -n 5", + f"rg -n '{CMD}' scripts", f"echo \"$(date) {CMD} blocked\"", f"echo a\\\\\\\n{CMD}", + f"# note: {CMD} later\ngit status", f"git log --grep '{CMD}' 2>&1 | head -n 5", + f"grep -rn '{CMD}' docs/ 2>/dev/null", + f"git -C /repo log --grep '{CMD}'", f"git commit -m 'x' -m '{CMD}' 2>&1", + f"echo '{CMD}' >&2", f"echo '{CMD}' >/dev/null 2>&1", + f"git diff -- docs | grep '{CMD}'", + f"git commit -m \"$(cat <<'EOF'\n# Title\nit's about {CMD}\nEOF\n)\"", + f"echo $(cat </dev/null {CMD} --fill", f"2>&1 {CMD}", f"< /dev/null {CMD}", + f"timeout 600 {CMD}", f"nice -n 5 {CMD}", f"env -i PATH=/usr/bin {CMD}", f"sudo -u root {CMD}", f"time -p {CMD}", + f"command -p {CMD}", f"exec -a x {CMD}", f"caffeinate -i {CMD}", f"python3 -c \"import os; os.system('{CMD}')\"", + f"echo '{CMD}' | sh", f"ksh -c '{CMD}'", f"echo x;>f {CMD}", f"cat <({CMD} --fill)", + f"diff a <({CMD})", f"bash < \"$TMPDIR/x.sh\"; bash \"$TMPDIR/x.sh\"", + "echo 'gh pr create --fill' | tee \"$TMPDIR/x.sh\"; sh \"$TMPDIR/x.sh\"", + # a `<<` that is not a heredoc: quoted, in a comment, in arithmetic, in a second operator, after an ANSI-C quote + 'echo "<&2 gh pr create --fill @\nEOF", + "cat <<'EOF' \"x\ny\"; gh pr create --fill; echo \"\nEOF\nz\"", + # a delimiter word whose backslash the shell removes, so its delimiter line is not the one the text spells + "cat </dev/null 2>&1", + # a `#` that does not start a comment, or a comment that does not swallow the next line + "echo ok # x \\\ngh pr create --fill", "echo ok # x\ngh pr create --fill", "echo ' #'; gh pr create --fill", + "echo a\\ #b; gh pr create --fill", "echo ${x:- # y}; gh pr create --fill", "echo `true # x` ; gh pr create --fill", + "echo 'a\n# b'; gh pr create --fill", "echo $'x' # y \\\ngh pr create --fill", "echo ok #c\\\ngh pr create --fill", + "echo a;#c\ngh pr create --fill", + # git running a command + "git rebase -x 'g\\h pr create --fill' HEAD~1", "git -C . bisect run gh pr create --fill", + "git submodule foreach 'gh pr create --fill'", "git grep -O 'gh pr create --fill' x", + "git --git-dir . rebase --exec 'gh pr create --fill' HEAD~1", + # `create` spelled by an expansion, an option that runs a command, a redirect after a newline, ANSI-C quoting + "gh pr create$x", "gh pr create${x}", "gh pr create$(true)", "gh pr create{,} --fill", "gh pr create* --fill", + "gh pr create$x\nprintf }\n", "git log\n>true gh pr create ", "grep fi$'\\''\ngh pr create --fill\n: echo 'echo ", + "git grep -nO'gh pr create --fill' x", "o=O; git grep -n${o}'gh pr create --fill' x", + "gh config set pager 'gh pr create --fill'", + # naming it while writing a file + "echo 'gh pr create --fill' >> run.sh", "printf 'gh pr create' &> run.sh", "echo 'gh pr create' | cat > x.sh", + "git log --grep 'gh pr create' > out.txt", + # a real run after a command that only names it + "git commit -m 'x' && gh pr create --fill", "git commit -m \"$(cat <<'EOF'\nx\nEOF\n)\" && gh pr create --fill", + "echo \"$(cat <<'EOF'\nx\nEOF\n)\"; gh pr create --fill", + # a config, alias or hook that git, gh or rg then runs, written beside a heredoc body that holds the text, by a git option + # (`--output`) or by a git subcommand that reads a patch on stdin + "cat >> .git/config <<'EOF'\n[alias]\n\tx = !gh pr create --fill\nEOF\ngit x", + "cat > gc < gc\n[alias]\n\tx = !gh pr create --fill\nEOF\nGIT_CONFIG_GLOBAL=gc git x", + "cat > gc <<'EOF'\n[core]\n\tfsmonitor = gh pr create --fill\nEOF\nGIT_CONFIG_GLOBAL=gc git status", + "cat <<'EOF' > cfg\n--pre=sh\nEOF\ncat > s.sh <<'EOF'\ngh pr create --fill\nEOF\nRIPGREP_CONFIG_PATH=cfg rg x s.sh", + "git log -1 --format='[alias]%n%x09x = !gh pr create --fill' --output=gc; GIT_CONFIG_GLOBAL=gc git x", + "git show -s --format='[alias]%n%x09x = !gh pr create --fill' --output=.git/config; git x", + "git log -1 --format='diff --git a/gc b/gc%nnew file mode 100644%n--- /dev/null%n+++ b/gc%n@@ -0,0 +1,2 @@%n+[alias]" + "%n+%x09x = !gh pr create --fill' | git apply; GIT_CONFIG_GLOBAL=gc git x", + "cat <<'EOF' | git apply\ndiff --git a/gc b/gc\nnew file mode 100644\n--- /dev/null\n+++ b/gc\n@@ -0,0 +1,2 @@\n" + "+[alias]\n+\tx = !gh pr create --fill\nEOF\nGIT_CONFIG_GLOBAL=gc git x", + # a variable that git runs (an editor, a config entry, an external diff), spelled by an expansion so that no word holds the + # text: set by an assignment, by `printf -v` or by `${NAME:=cmd}`; the mention beside it is what the old test denied + "echo gh pr create; GIT_EDITOR=\"$(echo gh) pr create\" git commit --allow-empty", + "echo gh pr create; GIT_EXTERNAL_DIFF=\"$(echo gh) pr create\" git diff --cached", + "echo gh pr create; GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=\"$(echo gh) pr create\" " + "git status", + "echo gh pr create; GIT_EDITOR=\"${X:-gh} pr create\" git commit --allow-empty", + "echo gh pr create; GIT_EDITOR=\"`echo gh` pr create\" git commit --allow-empty", + "echo gh pr create; X=gh; EDITOR=\"$X pr create\"; git commit --allow-empty", + "echo gh pr create; GIT_EDITOR='$A $B create' A=gh B=pr git commit --allow-empty", + "echo gh pr create; ( EDITOR=\"$(echo gh) pr create\"; git commit --allow-empty )", + "echo gh pr create; printf -v EDITOR 'gh pr create'; git commit --allow-empty", + "echo gh pr create; printf {-v,EDITOR} 'gh pr create'; git commit --allow-empty", + "echo gh pr create; o=-v; printf $o EDITOR 'gh pr create'; git commit --allow-empty", + "echo gh pr create; echo ${EDITOR:=gh pr create}; git commit --allow-empty", + # `--output=FILE` writes the mention as a config that git runs later, spelled by an expansion (no word starts with it) + "git commit -q --allow-empty -m i; o=--output=gc; git log -1 --format='[core]%n%x09fsmonitor = gh pr create --fill' " + "$o; GIT_CONFIG_GLOBAL=gc git status", + "git commit -q --allow-empty -m i; git log -1 --format='[core]%n%x09fsmonitor = gh pr create --fill' " + "\"$(echo --output=gc)\"; GIT_CONFIG_GLOBAL=gc git status", + "git commit -q --allow-empty -m i; git log -1 --format='[core]%n%x09fsmonitor = gh pr create --fill' " + "$(echo --output=gc); GIT_CONFIG_GLOBAL=gc git status", + "git commit -q --allow-empty -m i; x=--output=gc; git show -s --format='[core]%n%x09fsmonitor = gh pr create --fill' " + "\"$x\"; GIT_CONFIG_GLOBAL=gc git status", + "git commit -q --allow-empty -m i; git log -1 --format='[core]%n%x09fsmonitor = gh pr create --fill' -m " + "\"$(echo --output=gc)\"; GIT_CONFIG_GLOBAL=gc git status", + "git commit -q --allow-empty -m i; git log -1 --format='[core]%n%x09fsmonitor = gh pr create --fill' {--output=gc,}; " + "GIT_CONFIG_GLOBAL=gc git status", + # a continuation inside the text; `create` after flags, its alias `new`, `gh api`: each ran a stub gh beside a mention + "gh \\\npr create", "echo 'gh pr create'; gh pr new --fill", "gh pr new --fill # gh pr create", + "echo 'gh pr create'; gh -R o/r pr new --fill", "echo 'gh pr create'; gh pr -R o/r create --fill", + "echo 'gh pr create'; gh pr --repo=o/r create --fill", "echo 'gh pr create'; gh pr -t x create --fill", + "gh pr --repo=o/r create --fill # gh pr create", "echo 'gh pr create'; gh api -X POST repos/o/r/pulls -f title=x", + # a variable that git runs, set beside a mention (an editor, a config entry, an external diff) + "echo 'gh pr create'; GIT_EXTERNAL_DIFF='gh pr new --fill;:' git log -p --ext-diff", + "echo 'gh pr create'; GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='gh pr new --fill;:' " + "git status", + "echo 'gh pr create'; GIT_EDITOR='gh pr new --fill;:' git commit --allow-empty", + # the mention only in a comment: it counts as the text for a git subcommand outside the read set and for a file write + "printf 'gh pr cre%s --fill\\n' ate > x.sh; git rebase --exec 'sh x.sh' HEAD~1 # gh pr create", + "git rebase --exec 'gh pr new --fill;:' HEAD~1 # gh pr create", + "GIT_SEQUENCE_EDITOR='gh pr new --fill;:' git rebase -i HEAD~1 # gh pr create", + "FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --tree-filter 'gh pr new --fill;:' HEAD # gh pr create", + "git bisect start HEAD HEAD~1 >/dev/null; git bisect run gh pr new --fill # gh pr create", + "GD='gh pr new --fill;:' git --config-env=core.fsmonitor=GD status # gh pr create", + "# gh pr create\ng=gh; printf '[core]\\n\\tfsmonitor = %s pr create;:\\n' \"$g\" > gc; GIT_CONFIG_GLOBAL=gc git status", + "# gh pr create\ncat > gc <<'EOF'\n[core]\n\tfsmonitor = gh pr new --fill;:\nEOF\nGIT_CONFIG_GLOBAL=gc git status", + # a command word that is a path, another case, a comment beside a heredoc + "echo 'gh pr create'; ./echo gh pr create --fill", "GH pr create --fill", "Gh Pr Create --fill", "$GH pr create --fill", + "cat <<'EOF' # note\ngh pr create --fill\nEOF"] + # Known gaps: the text `gh pr create` is not in the command, so nothing here is gated. + RESIDUAL = ["${GH} pr create --fill", "$(which gh) pr create --fill", "gh${IFS}pr create --fill", "gh $'pr' create --fill", + "gh pr cre{a,}te --fill"] + # Only name it, and are denied anyway: a single-quoted backtick, a pipeline through a command outside SAFE, a git subcommand + # outside the read and record set beside a mention (also one only in a comment), a file written beside a mention (also one + # only in a heredoc body), a comment beside a heredoc, a quoted `#` before a heredoc, a `)` in a heredoc body inside `$(`, + # a `(` after the marker, a backslash in the delimiter word, a `gh` whose first two words hold `{` (its own `{owner}` + # placeholder), an expansion after `gh pr`, an assignment of a variable that is not inert, a `printf` with an option, an + # expansion that assigns, an expansion in a git word, a command word that is a path, and two that ran nothing in a stub: + # a forced pager and a config fed to git as a stream. + OVERDENY = [f"echo 'run `{CMD}` later'", f"echo '{CMD}' | sed 's/x/y/'", f"git tag -m '{CMD}' v1", + f"echo '#'; cat < notes.md <<'EOF'\nuse {CMD}\nEOF\ngit add notes.md", f"X=$(date +%s) git log --grep '{CMD}'", + f"printf -v x '%s' '{CMD}'; echo \"$x\"", f"echo \"${{x:=1}} {CMD}\"", + f"git log \"$base\"..HEAD --grep '{CMD}'", f"git -C \"$PWD\" log --grep '{CMD}'", + f"git push # then {CMD} later", f"cat > notes.md <<'EOF'\nit's how {CMD} works\nEOF", + f"cat > notes.md <<'EOF'\n# Heading\nuse {CMD}\nEOF", f"cat > notes.md <<'EOF'\nsee (use {CMD})\nEOF", + f"echo a # \\\ncat < x.sh", ">> x.sh", "&> x.sh", ">| x.sh", "2> err.txt", "| cat > x.sh", "| tee x.sh"): + self.assertEqual(self.rc(f"echo '{self.CMD}' {w}", 5), 2, w) + for w in ("2>&1", ">&2", ">/dev/null", "2>/dev/null", ">/dev/null 2>&1"): + self.assertEqual(self.rc(f"echo '{self.CMD}' {w}", 5), 0, w) + + def test_the_documented_residuals_still_pass_at_the_cap(self): + for c in self.RESIDUAL: + self.assertFalse(self.fa._holds(c), c) # the text is not in the command, so the gate never looks + self.assertEqual(self.rc(c, 5), 0, c) + # a `gh` alias or extension that already exists runs under its own name: nothing in the command spells `pr create` + c = f"echo '{self.CMD}'; gh newpr --fill" + self.assertEqual(self.rc(c, 5), 0, c) + + def test_a_variable_set_before_a_command_must_have_an_inert_name(self): + for name in ("GIT_EXTERNAL_DIFF", "GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "GIT_PAGER", "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", "GIT_SSH_COMMAND", "PAGER", "EDITOR", "VISUAL", "GH_BROWSER", + "RIPGREP_CONFIG_PATH", "X"): + self.assertEqual(self.rc(f"{name}=x git log --grep '{self.CMD}'", 5), 2, name) + for name in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", + "GIT_COMMITTER_DATE", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "NO_COLOR"): + self.assertEqual(self.rc(f"{name}=x git log --grep '{self.CMD}'", 5), 0, name) + + def test_the_documented_over_denies_stay_on_the_safe_side(self): + for c in self.OVERDENY: + self.assertEqual(self.rc(c, 5), 2, c) + + def test_text_without_the_command_is_never_gated_even_when_it_does_not_parse(self): + self.assertEqual(self.rc("echo 'unbalanced and no mention", 5), 0) + + def test_every_shape_passes_under_the_cap(self): + for c in self.MENTION + self.RUN + self.PROBE + self.RESIDUAL + self.OVERDENY: + self.assertEqual(self.rc(c, 4), 0, c) + + def test_priority_override_still_passes_at_the_cap(self): + self.assertEqual(self.rc(f"{self.CMD} --title x # priority: red trunk fix", 9), 0) + + if __name__ == "__main__": unittest.main()