From 5b2a1411760f44ea960d818fae6107c8a5811c02 Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 21 Oct 2026 07:06:36 +0800 Subject: [PATCH 1/4] emrg: a signaller is the act only as its statement's command word --- emrg/tools/command_scan.py | 93 ++++++++++++++++++++ tests/test_daemon_lifecycle_is_classified.py | 49 +++++++++++ 2 files changed, 142 insertions(+) diff --git a/emrg/tools/command_scan.py b/emrg/tools/command_scan.py index 5be64f8f2..0bcc64c52 100644 --- a/emrg/tools/command_scan.py +++ b/emrg/tools/command_scan.py @@ -206,6 +206,15 @@ def check_containment_escape(cmd: str) -> str | None: _PROCESS_SIGNALLER_WORDS = frozenset({"pkill", "killall", "kill"}) #: The sub-command word between the program and the verb (`emrg server stop`). _DAEMON_SUBCOMMAND_WORDS = frozenset({"server"}) +#: Programs that run the command after them rather than acting themselves, so a +#: signaller they hand the argv to is still the command being run: +#: `sudo -u root kill …`, `nice -n 5 pkill …`, `xargs kill …`. +_WRAPPER_WORDS = frozenset({ + "busybox", "caffeinate", "command", "doas", "env", "exec", "ionice", "nice", + "nohup", "stdbuf", "sudo", "time", "timeout", "xargs", +}) +#: `NAME=value` in front of a command — `env`-style handover, not a command word. +_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") _DAEMON_GROUPING_TOKENS = frozenset({"(", ")", "{", "}", "$", "`"}) @@ -264,6 +273,80 @@ def _operand_naming_emrg(tokens: list[str], start: int) -> str | None: return tok return None +def _signaller_is_the_command(tokens: list[str], i: int) -> bool: + """Is the signaller at ``tokens[i]`` the command word, not somebody's argument? + + The signaller half of this rule reads a *word*, and until it asked this + question that word could be any argument. Measured on `caf6d743`, the two + directions disagreed about the same command shape: + + * ``grep -rn kill emrg/`` was **refused** — `kill` here is the pattern and + `emrg/` the directory, so the read was called "it stops or restarts the + emrg daemon". The lifecycle-verb half has always read the same shape as a + mention (`grep -rn stop emrg/` sits in the allowed list of + `tests/test_daemon_lifecycle_is_classified.py`); the two halves now agree. + * ``pgrep -f emrg | xargs kill`` was **allowed** — and that one really does + signal the daemon. `xargs` is the command there, so asking this question + would have *opened* it; the wrapper set below is what closes it. + + So the walk starts at the statement's first token and steps over the words + that hand the argv on — wrappers (`sudo`, `nice`, `env`, `xargs`, `timeout`), + their flags, a flag's value (`sudo -u root kill`), a bare number (`timeout 5 + kill`), and `NAME=value` assignments — then demands that the first remaining + word *be* the signaller. + + The border test is the tokenizer's own, so newlines separate statements here + exactly as `;`, `&&` and `|` do: prose in a heredoc is a statement whose first + word is not a signaller. + """ + j = i - 1 + while j >= 0 and not _is_a_command_border(tokens[j]): + j -= 1 + j += 1 + takes_a_value = False + while j < i: + tok = tokens[j] + base = _basename(tok).lower() + if tok.startswith("-"): + takes_a_value = True + elif tok.isdigit(): + takes_a_value = False + elif takes_a_value: + takes_a_value = False + elif base in _WRAPPER_WORDS or _ASSIGNMENT_RE.match(tok): + takes_a_value = False + else: + return False + j += 1 + return True + +def _piped_target_naming_emrg(tokens: list[str], i: int) -> str | None: + """The name emrg is spelled under, when `xargs` is what will hand it to a signaller. + + `xargs` execs its command with **its own input appended as argv**, so in + ``pgrep -f emrg | xargs kill`` the target is spelled *before* the signaller — + the opposite of every other spelling, and the reason the operand scan alone + read that pipeline as allowed (measured on `caf6d743`). + + Read only when this statement's wrapper is `xargs`: a plain pipe into a + signaller (`… | kill -9`) passes nothing as argv, so looking back there would + be a guess rather than a reading. The walk stops at any border that is not a + single `|`, so `;`, `&&`, `||` and a newline all end the pipeline. + """ + j = i - 1 + while j >= 0 and not _is_a_command_border(tokens[j]): + j -= 1 + if not any(_basename(t).lower() == "xargs" for t in tokens[j + 1 : i]): + return None + while j >= 0 and tokens[j] == "|": + k = j - 1 + while k >= 0 and not _is_a_command_border(tokens[k]): + if "emrg" in tokens[k].lower(): + return tokens[k] + k -= 1 + j = k + return None + #: `env`'s string handover — the flag whose value `env` splits into an argv and #: execs. BSD/macOS `env` takes the short form only (measured on this host: #: `env --split-string ` answers `illegal option -- s`, while @@ -339,6 +422,12 @@ def stops_or_restarts_the_daemon(cmd: str, _depth: int = 0) -> str | None: `pkill -f 'python -m emrg'` without the name in the text, `kill 12345`. A process table is not a static reading, and widening the signaller to every interpreter name would refuse ordinary `pkill node` work; + * a signaller that is another program's *argument* rather than the command — + `grep -rn kill emrg/` searches a directory, and reading it as the act was + the over-block `_signaller_is_the_command` removes. The same reading + refuses `pgrep -f emrg | xargs kill`, where `xargs` hands its input on as + argv; a plain pipe into a signaller (`ps aux | grep emrg | kill -9`) + passes no argv, so nothing upstream is read for it; * a quoted argument of a program that cannot run it — `echo "emrg server stop"` is one argument and prints it. Reading every quoted argument as a command is the over-block #1513 removed from this file (`echo sh "patch @@ -360,7 +449,11 @@ def stops_or_restarts_the_daemon(cmd: str, _depth: int = 0) -> str | None: # rather than a curiosity. entry = i + 1 elif base in _PROCESS_SIGNALLER_WORDS: + if not _signaller_is_the_command(tokens, i): + continue named = _operand_naming_emrg(tokens, i + 1) + if named is None: + named = _piped_target_naming_emrg(tokens, i) if named is not None: return f"{tok} {named}" continue diff --git a/tests/test_daemon_lifecycle_is_classified.py b/tests/test_daemon_lifecycle_is_classified.py index bf1022c7f..6a98f2b7d 100644 --- a/tests/test_daemon_lifecycle_is_classified.py +++ b/tests/test_daemon_lifecycle_is_classified.py @@ -99,6 +99,15 @@ def _argv_side_reader(): "killall emrgd", 'kill $(pgrep -f "python -m emrg")', "nice -n 5 pkill -f emrg.server", + # a signaller reached through the wrappers that hand it the argv: the + # command word is still the signaller, so these are the act + "sudo -u root kill $(pgrep -f emrg)", + "env kill $(pgrep -f emrg)", + "timeout 5 kill $(pgrep -f emrg)", + # `xargs` appends its input as argv, so the target is spelled *before* the + # signaller — a plain pipe into `kill -9` passes nothing and stays a guess + "pgrep -f emrg | xargs kill", + "ps aux | grep emrg | awk '{print $2}' | xargs kill", # a shell re-parsing the text, and a substitution naming the program "sh -c 'emrg server stop'", 'bash -c "emrg server restart"', @@ -123,6 +132,14 @@ def _argv_side_reader(): "ls emrg", "cat emrg/tools/bash_tool.py", "grep -rn stop emrg/", + # the signaller half reads the command word too: here `kill` is a search + # pattern and `emrg/` the directory, so the read is a mention, exactly as + # `grep -rn stop emrg/` above already was + "grep -rn kill emrg/", + "git grep -n 'kill' -- emrg/tools/", + "grep kill argszero/emrg notes.txt", + # a pipeline that names the process but hands nothing to a signaller + "ps aux | grep emrg | tee /tmp/procs.txt", # a *quoted* mention is one argument, not a command line "echo 'emrg server stop'", # a signaller whose operand names somebody else — a stated limit, pinned @@ -172,6 +189,38 @@ def test_the_classifier_names_the_spelling_it_saw(): assert _stops_or_restarts_the_daemon("pkill -f emrg.server") == "pkill emrg.server" +def test_a_signaller_as_another_programs_argument_is_a_mention(): + """`grep -rn kill emrg/` and `grep -rn stop emrg/` are the same read. + + The lifecycle-verb half has always read a program named *after* its verb as a + mention, so `grep -rn stop emrg/` stayed allowed. The signaller half read any + `kill` token anywhere in the statement as the act, so the identical shape one + word over was refused as "it stops or restarts the emrg daemon" — a pair the + command never forms. Both halves now read the command word. + """ + for cmd in ( + "grep -rn stop emrg/", + "grep -rn kill emrg/", + "git grep -n 'kill' -- emrg/tools/", + "grep kill argszero/emrg notes.txt", + ): + assert _stops_or_restarts_the_daemon(cmd) is None, f"{cmd!r} was refused" + + +def test_xargs_appends_its_input_as_argv_so_the_target_is_read_upstream(): + """The pipeline that really signals the daemon, and the stated limit beside it. + + `xargs` execs its command with its own input appended as argv, so in + `pgrep -f emrg | xargs kill` the target is spelled *before* the signaller — + the operand scan alone read that as allowed, which was a live route to the act + this rule exists to prevent. A plain pipe into a signaller passes no argv, so + nothing upstream is read for it; that is a limit, pinned rather than assumed. + """ + hit = _stops_or_restarts_the_daemon("pgrep -f emrg | xargs kill") + assert hit is not None and "emrg" in hit + assert _stops_or_restarts_the_daemon("ps aux | grep emrg | kill -9") is None + + def test_the_verb_must_follow_the_program(): """A verb *before* the `emrg` word is somebody else's argument.""" assert _stops_or_restarts_the_daemon("git commit -m stop emrg") is None From 0132a2421375f1dbe98ff00452c964f4c066e4f9 Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 21 Oct 2026 09:13:49 +0800 Subject: [PATCH 2/4] emrg: a signaller that opens a command line is that line's command word --- emrg/tools/command_scan.py | 43 +++++++++++--- tests/test_daemon_lifecycle_is_classified.py | 59 ++++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/emrg/tools/command_scan.py b/emrg/tools/command_scan.py index 0bcc64c52..a84a30509 100644 --- a/emrg/tools/command_scan.py +++ b/emrg/tools/command_scan.py @@ -210,9 +210,21 @@ def check_containment_escape(cmd: str) -> str | None: #: signaller they hand the argv to is still the command being run: #: `sudo -u root kill …`, `nice -n 5 pkill …`, `xargs kill …`. _WRAPPER_WORDS = frozenset({ - "busybox", "caffeinate", "command", "doas", "env", "exec", "ionice", "nice", - "nohup", "stdbuf", "sudo", "time", "timeout", "xargs", + "busybox", "caffeinate", "command", "doas", "env", "eval", "exec", "ionice", + "nice", "nohup", "parallel", "ssh", "stdbuf", "su", "sudo", "time", + "timeout", "watch", "xargs", }) +#: Wrappers that also consume one bare operand before the command: `ssh +#: pkill …`. The first argument is a destination, not the command, so it is +#: stepped over rather than ending the walk. +_WRAPPERS_TAKING_AN_OPERAND = frozenset({"ssh", "su"}) +#: Tokens that open a command line of their own rather than prefixing one: a +#: grouping `(`, a command substitution `$(` or a backtick, and `-exec`/ +#: `-execdir`, which exec the command that follows. A signaller right after one +#: of these is that command line's first word — and the border test deliberately +#: leaves `$`, `(` and the backtick alone (so `kill $(pgrep -f emrg)` keeps its +#: target), which is why the start has to be read here instead of as a border. +_COMMAND_STARTERS = frozenset({"(", "`", "-exec", "-execdir"}) #: `NAME=value` in front of a command — `env`-style handover, not a command word. _ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") @@ -290,10 +302,20 @@ def _signaller_is_the_command(tokens: list[str], i: int) -> bool: would have *opened* it; the wrapper set below is what closes it. So the walk starts at the statement's first token and steps over the words - that hand the argv on — wrappers (`sudo`, `nice`, `env`, `xargs`, `timeout`), - their flags, a flag's value (`sudo -u root kill`), a bare number (`timeout 5 - kill`), and `NAME=value` assignments — then demands that the first remaining - word *be* the signaller. + that hand the argv on — wrappers (`sudo`, `nice`, `env`, `xargs`, `eval`, + `watch`, `ssh`, `timeout`), their flags, a flag's value (`sudo -u root + kill`), a bare number (`timeout 5 kill`), a wrapper's own destination (`ssh + pkill`) and `NAME=value` assignments — then demands that the first + remaining word *be* the signaller. + + A command line does not always begin at the statement's first token, though, + and there the walk alone is too narrow: the command word also sits right + after a command substitution `$(` or a backtick, after a grouping `(`, and + after `-exec`/`-execdir`. A signaller in that position is the command word of + the line it opens, whatever precedes it (`echo $(pkill -f emrg)`, + `x=$(pkill -f emrg)`, `find . -exec pkill -f emrg`). Those tokens are + deliberately *not* command borders — `kill $(pgrep -f emrg)` must keep its + target — so the position is read here instead of as a border. The border test is the tokenizer's own, so newlines separate statements here exactly as `;`, `&&` and `|` do: prose in a heredoc is a statement whose first @@ -303,6 +325,11 @@ def _signaller_is_the_command(tokens: list[str], i: int) -> bool: while j >= 0 and not _is_a_command_border(tokens[j]): j -= 1 j += 1 + # A signaller right after a token that opens a command line is that line's + # first word, whatever the statement around it looks like: `echo $(pkill -f + # emrg)`, `x=$(…)`, ``echo `pkill` ``, `find . -exec pkill -f emrg \;`. + if i > 0 and tokens[i - 1] in _COMMAND_STARTERS: + return True takes_a_value = False while j < i: tok = tokens[j] @@ -313,7 +340,9 @@ def _signaller_is_the_command(tokens: list[str], i: int) -> bool: takes_a_value = False elif takes_a_value: takes_a_value = False - elif base in _WRAPPER_WORDS or _ASSIGNMENT_RE.match(tok): + elif base in _WRAPPER_WORDS: + takes_a_value = base in _WRAPPERS_TAKING_AN_OPERAND + elif _ASSIGNMENT_RE.match(tok): takes_a_value = False else: return False diff --git a/tests/test_daemon_lifecycle_is_classified.py b/tests/test_daemon_lifecycle_is_classified.py index 6a98f2b7d..e81604cbd 100644 --- a/tests/test_daemon_lifecycle_is_classified.py +++ b/tests/test_daemon_lifecycle_is_classified.py @@ -108,6 +108,22 @@ def _argv_side_reader(): # signaller — a plain pipe into `kill -9` passes nothing and stays a guess "pgrep -f emrg | xargs kill", "ps aux | grep emrg | awk '{print $2}' | xargs kill", + "pgrep -f emrg | xargs -r kill", + # a command line the signaller opens rather than one it is prefixed by: a + # substitution, a grouping, a backtick, `find -exec`, and the programs whose + # bare argument is the command. All of these were refused before the + # command-position rule existed, and each really does signal the daemon. + "eval pkill -f emrg", + "echo $(pkill -f emrg)", + "x=$(pkill -f emrg)", + 'echo `pkill -f emrg`', + "(pkill -f emrg)", + "find . -maxdepth 0 -exec pkill -f emrg \\;", + "find /tmp -name x -exec killall emrgd \\;", + "watch -n 1 pkill -f emrg", + "parallel pkill -f emrg", + "ssh localhost pkill -f emrg", + "su root kill $(pgrep -f emrg)", # a shell re-parsing the text, and a substitution naming the program "sh -c 'emrg server stop'", 'bash -c "emrg server restart"', @@ -140,6 +156,16 @@ def _argv_side_reader(): "grep kill argszero/emrg notes.txt", # a pipeline that names the process but hands nothing to a signaller "ps aux | grep emrg | tee /tmp/procs.txt", + # the reads the wider wrapper set must not swallow: the wrapper runs the + # command after it, and that command is `grep`, not the signaller + "ssh host grep kill emrg/", + "watch grep kill emrg/", + "eval echo kill", + "parallel grep -rn kill emrg/", + "find . -exec grep -rn kill emrg/ \\;", + "(grep -rn kill emrg/)", + "echo $(grep -rn kill emrg/)", + "find . -name kill -o -name emrg", # a *quoted* mention is one argument, not a command line "echo 'emrg server stop'", # a signaller whose operand names somebody else — a stated limit, pinned @@ -221,6 +247,39 @@ def test_xargs_appends_its_input_as_argv_so_the_target_is_read_upstream(): assert _stops_or_restarts_the_daemon("ps aux | grep emrg | kill -9") is None +def test_a_signaller_that_opens_a_command_line_is_the_command_word(): + """A substitution, a grouping, `-exec` and `eval` each start a command line. + + Reading the statement's *first* word as the only candidate command word is + too narrow: the command word also sits after `$(`, a backtick and a grouping + `(`, after `-exec`/`-execdir`, and after the programs whose bare argument is + the command (`eval`, `watch`, `parallel`, `ssh`, `su`). Each of these really + signals the daemon, and each was refused before the command-position rule — + so missing them would be the rule *opening* a route rather than closing one. + + The counterpart is that the same position must not swallow a read: the + wrapper runs the command after it, and when that command is `grep` the + mention stays a mention. + """ + for cmd in ( + "eval pkill -f emrg", + "echo $(pkill -f emrg)", + "x=$(pkill -f emrg)", + "find . -maxdepth 0 -exec pkill -f emrg \\;", + "watch -n 1 pkill -f emrg", + "ssh localhost pkill -f emrg", + "parallel pkill -f emrg", + ): + assert _stops_or_restarts_the_daemon(cmd) is not None, f"{cmd!r} was allowed" + for cmd in ( + "ssh host grep kill emrg/", + "watch grep kill emrg/", + "find . -exec grep -rn kill emrg/ \\;", + "echo $(grep -rn kill emrg/)", + ): + assert _stops_or_restarts_the_daemon(cmd) is None, f"{cmd!r} was refused" + + def test_the_verb_must_follow_the_program(): """A verb *before* the `emrg` word is somebody else's argument.""" assert _stops_or_restarts_the_daemon("git commit -m stop emrg") is None From 743931f4e349436745b05de712a911df84fc8223 Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 21 Oct 2026 11:27:09 +0800 Subject: [PATCH 3/4] emrg: a statement opener puts the signaller in the command position --- emrg/tools/command_scan.py | 38 ++++++++++++--- tests/test_daemon_lifecycle_is_classified.py | 49 ++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/emrg/tools/command_scan.py b/emrg/tools/command_scan.py index a84a30509..af7109d85 100644 --- a/emrg/tools/command_scan.py +++ b/emrg/tools/command_scan.py @@ -225,6 +225,18 @@ def check_containment_escape(cmd: str) -> str | None: #: leaves `$`, `(` and the backtick alone (so `kill $(pgrep -f emrg)` keeps its #: target), which is why the start has to be read here instead of as a border. _COMMAND_STARTERS = frozenset({"(", "`", "-exec", "-execdir"}) +#: Reserved words and operators that open a command position inside a statement: +#: `{ cmd; }`, `! cmd`, and the compound forms `if …; then cmd`, `while …; do cmd`, +#: `until …; do cmd`, `for x in …; do cmd`, `else cmd`. The walk below starts after +#: the previous `;`, so after `then` or `do` it sees `then` / `do` as the first word +#: and answers "not the act" — measured on `5b2a1411`/`0132a242`, every one of +#: `{ pkill -f emrgd; }`, `if true; then pkill -f emrgd; fi`, +#: `while true; do pkill -f emrgd; done` and `! pkill -f emrgd` was **allowed**, +#: and each of them really signals the daemon. Named as a group rather than one at a +#: time because these are several spellings of one position. +_STATEMENT_OPENERS = frozenset({ + "!", "{", "do", "elif", "else", "for", "if", "then", "until", "while", +}) #: `NAME=value` in front of a command — `env`-style handover, not a command word. _ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") @@ -310,10 +322,11 @@ def _signaller_is_the_command(tokens: list[str], i: int) -> bool: A command line does not always begin at the statement's first token, though, and there the walk alone is too narrow: the command word also sits right - after a command substitution `$(` or a backtick, after a grouping `(`, and - after `-exec`/`-execdir`. A signaller in that position is the command word of - the line it opens, whatever precedes it (`echo $(pkill -f emrg)`, - `x=$(pkill -f emrg)`, `find . -exec pkill -f emrg`). Those tokens are + after a command substitution `$(` or a backtick, after a grouping `(`, after + `-exec`/`-execdir`, and after a statement opener (`{`, `!`, `then`, `do`, + `else`, …). A signaller in that position is the command word of the line it + opens, whatever precedes it (`echo $(pkill -f emrg)`, `x=$(pkill -f emrg)`, + `find . -exec pkill -f emrg`, `if true; then pkill -f emrg`). Those tokens are deliberately *not* command borders — `kill $(pgrep -f emrg)` must keep its target — so the position is read here instead of as a border. @@ -328,7 +341,9 @@ def _signaller_is_the_command(tokens: list[str], i: int) -> bool: # A signaller right after a token that opens a command line is that line's # first word, whatever the statement around it looks like: `echo $(pkill -f # emrg)`, `x=$(…)`, ``echo `pkill` ``, `find . -exec pkill -f emrg \;`. - if i > 0 and tokens[i - 1] in _COMMAND_STARTERS: + if i > 0 and ( + tokens[i - 1] in _COMMAND_STARTERS or tokens[i - 1] in _STATEMENT_OPENERS + ): return True takes_a_value = False while j < i: @@ -470,6 +485,17 @@ def stops_or_restarts_the_daemon(cmd: str, _depth: int = 0) -> str | None: tokens = _tokenize_command(cmd) for i, tok in enumerate(tokens): base = _basename(tok).lower() + # `{pkill …` arrives as one token, so the signaller is not recognisable by + # the bare word: the brace fuses to it. Stripping it is a reading of the + # same position `{ pkill` occupies, and it is the one spelling the tokenizer + # does not separate — measured allowed on `5c9ed63b` as well, so it is a + # hole this rule inherits rather than one it opened. + opened_group = False + if base not in _PROCESS_SIGNALLER_WORDS and base not in _EMRG_PROGRAM_WORDS: + stripped = base.lstrip("{") + if stripped != base and stripped in _PROCESS_SIGNALLER_WORDS: + base = stripped + opened_group = True if base in _EMRG_PROGRAM_WORDS: entry = i elif tok == "-m" and tokens[i + 1 : i + 2] in (["emrg"], ["emrg.server"]): @@ -478,7 +504,7 @@ def stops_or_restarts_the_daemon(cmd: str, _depth: int = 0) -> str | None: # rather than a curiosity. entry = i + 1 elif base in _PROCESS_SIGNALLER_WORDS: - if not _signaller_is_the_command(tokens, i): + if not opened_group and not _signaller_is_the_command(tokens, i): continue named = _operand_naming_emrg(tokens, i + 1) if named is None: diff --git a/tests/test_daemon_lifecycle_is_classified.py b/tests/test_daemon_lifecycle_is_classified.py index e81604cbd..c6e4188f9 100644 --- a/tests/test_daemon_lifecycle_is_classified.py +++ b/tests/test_daemon_lifecycle_is_classified.py @@ -124,6 +124,19 @@ def _argv_side_reader(): "parallel pkill -f emrg", "ssh localhost pkill -f emrg", "su root kill $(pgrep -f emrg)", + # a statement opener puts the signaller in the command position: a brace group, + # a negated command, and the compound forms whose body follows `then` / `do`. + # Each was allowed before the openers were named, and each really signals. + "{ pkill -f emrgd; }", + "{ pkill -f emrgd ; }", + "bash -c '{ pkill -f emrgd; }'", + "if true; then pkill -f emrgd; fi", + "while true; do pkill -f emrgd; done", + "until false; do killall emrgd; done", + "for x in 1; do pkill -f emrg; done", + "! pkill -f emrgd", + # the brace fuses to the word here, so the tokenizer never separates them + "{pkill -f emrgd;}", # a shell re-parsing the text, and a substitution naming the program "sh -c 'emrg server stop'", 'bash -c "emrg server restart"', @@ -166,6 +179,13 @@ def _argv_side_reader(): "(grep -rn kill emrg/)", "echo $(grep -rn kill emrg/)", "find . -name kill -o -name emrg", + # an opener leads to the command that follows it, and that command may be a + # read — the opener set must not turn every mention after it into the act + "if true; then grep -rn kill emrg/; fi", + "while true; do grep kill emrg/; done", + "! grep -rn kill emrg/", + "{ grep -rn kill emrg/; }", + "for f in *; do grep -n kill \"$f\"; done", # a *quoted* mention is one argument, not a command line "echo 'emrg server stop'", # a signaller whose operand names somebody else — a stated limit, pinned @@ -512,3 +532,32 @@ def test_full_access_is_exempt_on_the_live_path(boundary): for command in ("emrg server stop", "pkill -f emrg.server"): content, reached = _run(BashToolV2(), command, "danger-full-access") assert reached is True, f"{command!r} was read at full access: {content!r}" + + +def test_a_statement_opener_puts_the_signaller_in_the_command_position(): + """`{ …; }`, `! …`, `if …; then …` and `while …; do …` all run their body. + + The command-position walk starts after the previous `;`, so after `then` or `do` + the first word it saw was `then` / `do` rather than the signaller, and the rule + answered "not the act". Every spelling below really signals the daemon, and each + was allowed before the openers were named — the same hole as the substitution + and `-exec` shapes, one position over. + + The counterpart is that an opener leads to whatever command follows it, and that + command may be a read: `if true; then grep -rn kill emrg/; fi` stays allowed. + """ + for cmd in ( + "{ pkill -f emrgd; }", + "if true; then pkill -f emrgd; fi", + "while true; do pkill -f emrgd; done", + "! pkill -f emrgd", + "{pkill -f emrgd;}", + ): + assert _stops_or_restarts_the_daemon(cmd) is not None, f"{cmd!r} was allowed" + for cmd in ( + "if true; then grep -rn kill emrg/; fi", + "while true; do grep kill emrg/; done", + "! grep -rn kill emrg/", + "{ grep -rn kill emrg/; }", + ): + assert _stops_or_restarts_the_daemon(cmd) is None, f"{cmd!r} was refused" From ee0a6dad33fc5d58e9201d7027e5f6a8dfa30e9a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 21 Oct 2026 12:36:52 +0800 Subject: [PATCH 4/4] emrg: the two wrapper vocabularies are one list, so a prefix is a route either reader sees --- emrg/tools/command_scan.py | 24 +++++++++++++++++--- tests/test_daemon_lifecycle_is_classified.py | 18 +++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/emrg/tools/command_scan.py b/emrg/tools/command_scan.py index af7109d85..d0a5c6125 100644 --- a/emrg/tools/command_scan.py +++ b/emrg/tools/command_scan.py @@ -213,11 +213,29 @@ def check_containment_escape(cmd: str) -> str | None: "busybox", "caffeinate", "command", "doas", "env", "eval", "exec", "ionice", "nice", "nohup", "parallel", "ssh", "stdbuf", "su", "sudo", "time", "timeout", "watch", "xargs", -}) +}) | _COMMAND_WRAPPERS +#: The union above is the cure for a measured hole, not tidiness. This module +#: holds two vocabularies that answer the same question — which word hands the +#: argv on — and until they were joined they disagreed by six words, every one a +#: route to the daemon: on `743931f4`, `setsid pkill -f emrg`, `builtin kill +#: $(pgrep -f emrg)`, `chroot / pkill -f emrg`, `unshare -r pkill -f emrg`, +#: `nsenter -t 1 pkill -f emrg` and `chrt 1 pkill -f emrg` were all **allowed**, +#: while `_runs_as_a_command` (a few hundred lines below) already read those six +#: prefixes as commands. `-exec`/`-execdir` ride along and change nothing: a token +#: starting with `-` is read as a flag before the list is consulted. +#: +#: The union keeps *this* walk rather than replacing it with `_runs_as_a_command`, +#: because the two skip different things and each skip is load-bearing: the operand +#: skip below is what `ssh localhost pkill -f emrg` and `parallel pkill -f emrg` +#: need (the other walk reads `localhost` as the command and answers "not the +#: act"), and the flag-value skip is what `sudo -u root kill` needs. Joining the +#: vocabularies without keeping the walk would trade six holes for two. #: Wrappers that also consume one bare operand before the command: `ssh #: pkill …`. The first argument is a destination, not the command, so it is -#: stepped over rather than ending the walk. -_WRAPPERS_TAKING_AN_OPERAND = frozenset({"ssh", "su"}) +#: stepped over rather than ending the walk. `chroot` puts a bare *path* there — +#: `chroot / pkill -f emrg` — which is neither a flag's value nor a number, so the +#: walk needs it named to get past the argument. +_WRAPPERS_TAKING_AN_OPERAND = frozenset({"ssh", "su", "chroot"}) #: Tokens that open a command line of their own rather than prefixing one: a #: grouping `(`, a command substitution `$(` or a backtick, and `-exec`/ #: `-execdir`, which exec the command that follows. A signaller right after one diff --git a/tests/test_daemon_lifecycle_is_classified.py b/tests/test_daemon_lifecycle_is_classified.py index c6e4188f9..4282fbd28 100644 --- a/tests/test_daemon_lifecycle_is_classified.py +++ b/tests/test_daemon_lifecycle_is_classified.py @@ -137,6 +137,16 @@ def _argv_side_reader(): "! pkill -f emrgd", # the brace fuses to the word here, so the tokenizer never separates them "{pkill -f emrgd;}", + # the six prefixes the two wrapper vocabularies disagreed about. Every one of + # them was *allowed* while the walk asked its own shorter list, and every one + # really puts the signaller in command position — `_runs_as_a_command` already + # read all six as commands, which is how the disagreement was found. + "setsid pkill -f emrg", + "builtin kill $(pgrep -f emrg)", + "chroot / pkill -f emrg", + "unshare -r pkill -f emrg", + "nsenter -t 1 pkill -f emrg", + "chrt 1 pkill -f emrg", # a shell re-parsing the text, and a substitution naming the program "sh -c 'emrg server stop'", 'bash -c "emrg server restart"', @@ -179,6 +189,14 @@ def _argv_side_reader(): "(grep -rn kill emrg/)", "echo $(grep -rn kill emrg/)", "find . -name kill -o -name emrg", + # the six prefixes the wider list admitted lead to the command that follows + # them, and that command may still be a read: joining two vocabularies must + # add routes to the act, not turn every mention behind a prefix into it + "setsid grep -rn kill emrg/", + "builtin grep -rn kill emrg/", + "chroot / grep -rn kill emrg/", + "unshare grep kill emrg/", + "nsenter grep kill emrg/", # an opener leads to the command that follows it, and that command may be a # read — the opener set must not turn every mention after it into the act "if true; then grep -rn kill emrg/; fi",