diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index dc0da435..c622c12f 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -1440,6 +1440,217 @@ def _check_containment_escape(cmd: str) -> str | None: return None +# ── The daemon's own life (host red line, issue #1324) ────────────────────── +# +# Never stopping or restarting the emrg server is the project's highest-priority +# rule (`MANIFESTO.md` 第四条附则二, host 2026-08-18T22:58), and until this rule it +# was enforced in one place (the test harness) and stated in two (the manifesto +# and a host-configured session prompt) — but classified **nowhere a shell +# command passes through**. Measured by the predicate alone on `1f2feefa`, the +# workspace-write tier: `emrg server stop`, `emrg server restart`, +# `pkill -f emrg.server`, `killall emrgd` and `kill $(pgrep -f "python -m emrg")` +# were all ordinary allowed commands. That is the shape the host actually hit — +# on 2026-08-21T09:41 a session in another project ran a "measurement" whose +# subprocess signalled the daemon, the TUI answered `server connection lost`, and +# the host asked *"你怎么验证的,怎么把 emrg server重启了?"*. +# +# The act, not the mention, is what is classified: the emrg program carrying a +# lifecycle verb, or a process signaller whose operand names emrg. A name that +# merely *appears* stays allowed — `git log --grep emrg`, `ls emrg`, `echo +# 'emrg server stop'` are all read commands, and a rule that refused them would +# teach the next reader to distrust it (the #1513 lesson: `echo sh "patch …"` was +# a bug, not a safe over-block). +_EMRG_PROGRAM_WORDS = frozenset({"emrg", "emrgd"}) +#: The verbs that end a running daemon's life: `emrg server stop`, +#: `emrg server restart`, and `emrg stop` (which stops daemon + TUI + GUI). +_DAEMON_LIFECYCLE_VERBS = frozenset({"stop", "restart"}) +#: Programs whose whole purpose is to signal another process by name. +_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"}) +#: Substitution and grouping scaffolding — tokens that occupy no slot of their +#: own, so the verb walk steps over them. `$(which emrg) server stop` tokenizes +#: as `$ ( which emrg ) server stop`, and without this the `)` between the +#: program and the verb ended the walk (measured: the shape was allowed). +_DAEMON_GROUPING_TOKENS = frozenset({"(", ")", "{", "}", "$", "`"}) + + +def _is_a_command_border(tok: str) -> bool: + """Does this token separate one command from the next? + + The tokenizer fuses adjacent punctuation, so `;;`, `&&` and `|&` arrive as + single tokens: the test is "every character is a separator character", which + covers the fused spellings without enumerating them. Grouping operators are + deliberately **not** borders — `kill $(pgrep -f emrg)` names its target + inside a substitution, and stopping at `$` or `(` would lose it. + """ + return bool(tok) and all(c in ";&|\n" for c in tok) + + +def _daemon_lifecycle_verb_after(tokens: list[str], start: int) -> tuple[str, int] | None: + """The lifecycle verb this statement carries after the emrg program, and where. + + Only the words a real invocation may put between the program and its verb + are stepped over — flags (`emrg --verbose server stop`) and the `server` + sub-command — and the walk stops at the first other word. Stopping there is + what keeps `git -C log --grep restart` allowed: the + `emrg` there is a directory *name*, and `log` is not a verb of the daemon. + """ + j = start + while j < len(tokens): + tok = tokens[j] + if _is_a_command_border(tok): + return None + word = _basename(tok).lower() + if word in _DAEMON_LIFECYCLE_VERBS: + return word, j + if ( + tok.startswith("-") + or word in _DAEMON_SUBCOMMAND_WORDS + or tok in _DAEMON_GROUPING_TOKENS + ): + j += 1 + continue + return None + return None + + +def _operand_naming_emrg(tokens: list[str], start: int) -> str | None: + """The operand a signaller's target is spelled in, if it names emrg. + + Read from the text of the operands rather than from a process table: the + guard is a static scan, so what it can see is the name the command spells — + `pkill -f emrg.server`, `killall emrgd`, `kill $(pgrep -f "python -m emrg")` + (the substitution's tokens include the pattern as one word). A signaller + whose target is spelled some other way is a stated limit, not a hole this + rule claims to cover; see `_stops_or_restarts_the_daemon`'s docstring. + """ + for tok in tokens[start:]: + if _is_a_command_border(tok): + return None + if "emrg" in tok.lower(): + return tok + 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 +#: `env -S 'printf RAN'` prints `RAN`), GNU coreutils takes both spellings, so +#: both are read: this is a static reading and the tree is developed on both. +_ENV_SPLIT_STRING_FLAGS = frozenset({"-S", "--split-string"}) + + +def _env_split_string_texts(tokens: list[str]) -> list[str]: + """The command texts an `env` invocation hands over as one argument. + + `env -S 'emrg server stop'` splits its value into an argv and execs it, so + the act is *in the string*, whatever the spelling — measured on this host + with harmless payloads: `env -S 'printf RAN'` prints `RAN`, and + `env -S 'printf %s' MARK` prints `MARK`, i.e. the tokens written after the + string join the argv `env` execs. + + The text is **argv-shaped, not shell text**: there is no shell to read a + redirect, so `env -S 'printf %s RAN > '` prints `RAN>` and writes no + file (measured). That is why this reader belongs to the act classifier and + is deliberately **not** a source inside `_nested_command_texts`: reading the + string as shell text there would refuse writes that cannot happen — the + #1513 over-block, one level down. + + Joining the string with the tokens that follow it and re-tokenising can only + widen the reading (`env` itself splits on whitespace and honours quotes), and + widening is the side this guard takes. + """ + out: list[str] = [] + for i, tok in enumerate(tokens): + if _basename(tok) != "env": + continue + j = i + 1 + while j < len(tokens) and not _is_a_command_border(tokens[j]): + tok_j = tokens[j] + if tok_j in _ENV_SPLIT_STRING_FLAGS: + value = tokens[j + 1 : j + 2] + if value: + rest: list[str] = [] + k = j + 2 + while k < len(tokens) and not _is_a_command_border(tokens[k]): + rest.append(tokens[k]) + k += 1 + out.append(" ".join(value + rest)) + elif tok_j.startswith("--split-string="): + out.append(tok_j.split("=", 1)[1]) + j += 1 + return out + + +def _stops_or_restarts_the_daemon(cmd: str, _depth: int = 0) -> str | None: + """The spelling of the act, if ``cmd`` stops or restarts the emrg daemon. + + Returns a short name of the spelling (for the refusal message) or ``None``. + Consulted by `_check_sandbox` on both checked tiers, because the act is not + a write and the write-target scans cannot see it: `emrg server stop` names no + file and no git verb, and `pkill -f emrg.server` writes nothing at all. + + What is read is the *act*: an emrg program word (`emrg`, `emrgd`, a path to + one, or `python -m emrg`) followed by `stop`/`restart`, optionally through + the `server` sub-command and flags; or a signaller (`pkill`, `killall`, + `kill`) one of whose operands names emrg. `_nested_command_texts` is asked + for the texts a shell re-parses, so `sh -c 'emrg server stop'` and + `eval 'emrg server restart'` are classified as the same act rather than as a + string literal — the same walk the write-target rule already recurses + through. `env -S ""` is read by `_env_split_string_texts`: execing + the argv that string splits into is `env`'s documented job, so that spelling + is the act too, and it is read *here* rather than in the write walk because + the text is argv-shaped — no shell reads the redirects in it. + + **Stated limits** (refused-direction bias does not apply here: each of these + is *allowed*, and none is an ordinary route to the daemon): + + * a signaller whose operand does not name emrg — `killall python`, + `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 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 + /etc/hosts"` was a false refusal), so the payload read is the shell's + (`sh -c`, `eval`) and `env -S`'s, whose whole purpose is to split and exec + it; + * a name built at runtime (`$EMRG server stop`, `emrg${X} server stop`). + """ + if _depth >= 3: + return None + tokens = _tokenize_command(cmd) + for i, tok in enumerate(tokens): + base = _basename(tok).lower() + if base in _EMRG_PROGRAM_WORDS: + entry = i + elif tok == "-m" and tokens[i + 1 : i + 2] in (["emrg"], ["emrg.server"]): + # The interpreter spelling — `python -m emrg server stop` — is how + # this repo runs its own CLI, so it is a route to the live daemon + # rather than a curiosity. + entry = i + 1 + elif base in _PROCESS_SIGNALLER_WORDS: + named = _operand_naming_emrg(tokens, i + 1) + if named is not None: + return f"{tok} {named}" + continue + else: + continue + verb = _daemon_lifecycle_verb_after(tokens, entry + 1) + if verb is not None: + return " ".join( + t + for t in tokens[entry : verb[1] + 1] + if t not in _DAEMON_GROUPING_TOKENS + ) + for nested in _nested_command_texts(tokens) + _env_split_string_texts(tokens): + hit = _stops_or_restarts_the_daemon(nested, _depth + 1) + if hit is not None: + return hit + return None + + # ── Windows path spellings (issue #1261) ──────────────────────────────────── # # The guard tokenises with `shlex` in POSIX mode, where a backslash is an @@ -6516,6 +6727,23 @@ def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[boo if escape: return False, escape, "partial" + # The daemon's life is the one act neither target scan can see (issue + # #1324): it names no file and no git verb, so `emrg server stop`, + # `pkill -f emrg.server` and `killall emrgd` reached this point as ordinary + # commands at *both* checked tiers. The server is EMRG's body rather than a + # resource a tool call may dispose of — stopping it drops the host's + # connection and rebuilds every scheduled handler — so a shell command under + # a checked tier never ends it. `danger-full-access` returns above: there + # the host's own instruction is the only rule, and it always was. + stops = _stops_or_restarts_the_daemon(cmd) + if stops: + return False, ( + f"sandbox: blocked {stops!r} — it stops or restarts the emrg daemon, " + "which is EMRG's life core (host red line, issue #1324). Restart it " + "from the host's own terminal; `emrg pause` / `emrg resume` pause the " + "evolution instead of the server." + ), "partial" + targets = _extract_write_targets(cmd) + _unresolved_wrapper_targets(cmd) if mode == "read-only": for t in targets: diff --git a/tests/conftest.py b/tests/conftest.py index dee2694d..d3a9151c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -527,7 +527,51 @@ def _emrg_entry_index(tokens, *, shell_parsed: bool = False) -> int: return -1 -def _spawns_a_daemon_stop_or_restart(args, *, shell_parsed: bool = False) -> bool: +#: `env`'s string handover: the flag whose *value* `env` splits into an argv and +#: execs. BSD/macOS `env` takes `-S ` (measured on this host: `env -S +#: 'printf RAN'` prints `RAN`, `env -S 'printf %s' MARK` prints `MARK`), GNU +#: coreutils also takes `--split-string`. +_ENV_SPLIT_STRING_FLAGS = ("-S", "--split-string") + + +def _env_split_string_argvs(tokens) -> list[list[str]]: + """The argvs an ``env -S …`` hands over, for the guard to classify. + + `env -S "emrg server stop"` execs `emrg` with the argv `["server", "stop"]`: + the act, spelled as somebody else's argument. Measured, the tokens written + after the string join the argv `env` execs, so both are read together. + + A **list** argv is where this reader is reachable. A shell line is split on + whitespace before it arrives, so `sh -c 'env -S "emrg server stop"'` already + arrives as the act's own token stream (that is the corpus row pinned in + `tests/test_hermeticity_guard.py`); the argv spelling did not, which left the + one reading of the act the harness could really spawn. + + The string is split on whitespace and judged with ``shell_parsed=True``, + which is what `env`'s own splitter does with quotes and escapes (`env -S + 'printf "RAN"'` prints `RAN`, not `"RAN"` — measured) — and that reading can + only widen, the direction this guard takes. Nesting is bounded rather than + trusted, for the same reason the product's classifier bounds it: neither is + allowed to hang on adversarial input. + """ + out: list[list[str]] = [] + for i, token in enumerate(tokens): + if "env" not in _basenames(token): + continue + for j in range(i + 1, len(tokens)): + tok = str(tokens[j]) + if tok in _ENV_SPLIT_STRING_FLAGS: + value, rest = tokens[j + 1 : j + 2], tokens[j + 2 :] + elif tok.startswith("--split-string="): + value, rest = [tok.split("=", 1)[1]], tokens[j + 1 :] + else: + continue + if value: + out.append(str(value[0]).split() + [str(t) for t in rest]) + return out + + +def _spawns_a_daemon_stop_or_restart(args, *, shell_parsed: bool = False, _depth: int = 0) -> bool: """Would this `Popen` argv stop or restart the emrg daemon? Keyed on the **act**, not on the mention: the emrg program carrying a @@ -567,6 +611,17 @@ def _spawns_a_daemon_stop_or_restart(args, *, shell_parsed: bool = False) -> boo * `[..., "emrg", "server", "(stop)"]` — the *verb* glued to punctuation, no shell involved at all. + **`env -S` is read on both sides of that line**, which is the second revision + of this rule (review of cycle `cyc20260922-032844`): the *string* is what + `env` splits and execs, so it is the act in a shell line (`sh -c 'env -S + "emrg server stop"'`, above — the split-on-whitespace path already reaches + it) and in a list argv (`["env", "-S", "emrg server stop"]`, which this + reader missed while the product's classifier reads it). What is *not* read is + the same string in the **write** walk — it is argv-shaped, not shell text: + measured, `env -S 'printf %s RAN > '` prints `RAN>` and writes no + file, so reading it as shell text there would refuse a write that cannot + happen. + The rules they all sit on top of are unchanged, and the shapes that must stay allowed are pinned beside these in `tests/test_hermeticity_guard.py`: the verb must come *after* the program, so `git commit -m stop` and `emrg --help | grep @@ -641,6 +696,13 @@ def _spawns_a_daemon_stop_or_restart(args, *, shell_parsed: bool = False) -> boo ): return True + if _depth < 3: + for borrowed in _env_split_string_argvs(tokens): + if _spawns_a_daemon_stop_or_restart( + borrowed, shell_parsed=True, _depth=_depth + 1 + ): + return True + entry = _emrg_entry_index(tokens, shell_parsed=shell_parsed) if entry < 0: return False diff --git a/tests/test_daemon_lifecycle_is_classified.py b/tests/test_daemon_lifecycle_is_classified.py new file mode 100644 index 00000000..41f3c927 --- /dev/null +++ b/tests/test_daemon_lifecycle_is_classified.py @@ -0,0 +1,262 @@ +"""The daemon's life is classified, not only stated (issue #1324). + +Never stopping or restarting the emrg server is the project's highest-priority +rule (host 2026-08-18T22:58, `MANIFESTO.md` 第四条附则二). Until `_stops_or_restarts_the_daemon` +it was enforced in **one** place and stated in **two** — and classified nowhere a +shell command passes through, which is the shape the host actually hit: on +2026-08-21T09:41 a session in another project ran a "measurement" whose +subprocess signalled the daemon, the TUI answered `server connection lost`, and +the host asked *"你怎么验证的,怎么把 emrg server重启了?"*. + +Every test here asks the **pure predicate** (`_check_sandbox`) or the classifier +directly. Nothing in this file drives `BashTool.execute` with a lifecycle +command: a test that really reached the daemon would be the incident, not a +proof — and a test's safety must not depend on the code it is testing. The +argv-side reader of the same act (`tests/conftest.py::_spawns_a_daemon_stop_or_restart`) +has its own corpus in `tests/test_hermeticity_guard.py`, which asks for the +refusal *before* anything is signalled or spawned. +""" +from __future__ import annotations + +import shlex +import sys +from pathlib import Path + +import pytest + +from emrg.tools.bash_tool import ( + _check_sandbox, + _nested_command_texts, + _stops_or_restarts_the_daemon, + _tokenize_command, +) + + +def _argv_side_reader(): + """`tests/conftest.py`'s classifier, from the copy pytest already loaded. + + Read out of `sys.modules` by file identity rather than `import conftest`: + whether the tests directory is on `sys.path` depends on pytest's import mode, + while the rootdir conftest is loaded under *some* name in every run — and a + second copy of that module (executed through `importlib`) would install the + red-line guards twice. + """ + wanted = (Path(__file__).with_name("conftest.py")).resolve() + for module in list(sys.modules.values()): + path = getattr(module, "__file__", None) + if path and Path(path).resolve() == wanted: + return module._spawns_a_daemon_stop_or_restart + raise AssertionError("tests/conftest.py is not loaded in this run") + + +#: Rows that stop or restart the daemon — the act, in the spellings a command +#: line can carry it. Each is refused at both checked tiers. +THE_ACT = [ + # the CLI's own two verbs, and the one that also takes the clients with it + "emrg server stop", + "emrg server restart", + "emrg stop", + "emrg stop --skip-gui", + # a path to the program, and the interpreter spelling this repo itself uses + "/usr/local/bin/emrg server restart", + "python -m emrg server stop", + "python -m emrg.server server stop", + # a prefix hands the program over + "uv run emrg server stop", + "env emrg server stop", + "nohup emrg server restart", + "timeout 5 emrg server stop", + # one string that `env` splits into an argv and execs (`-S` on BSD/macOS, + # `--split-string` on GNU coreutils — both read, `=` spelling included) + 'env -S "emrg server stop"', + "env -S 'emrg server restart'", + 'env --split-string "emrg server stop"', + "env --split-string='emrg server stop'", + # the tokens after the string join that argv, so the act is there too + "env -S emrg server stop", + "env -i -S 'emrg server restart'", + # a known cost of reading the string: the act rule takes *any* position for + # the program word (measured beside `echo emrg server stop`, which it has + # refused since the rule landed), so a mention in a string `env` would never + # see is refused too. Loud and cheap, the side this guard takes. + "echo env -S 'emrg server stop'", + # a flag between the program and its verb + "emrg --verbose server stop", + # a signaller whose operand names emrg, in all three spellings + "pkill -f emrg.server", + 'pkill -f "python -m emrg"', + "killall emrgd", + 'kill $(pgrep -f "python -m emrg")', + "nice -n 5 pkill -f emrg.server", + # a shell re-parsing the text, and a substitution naming the program + "sh -c 'emrg server stop'", + 'bash -c "emrg server restart"', + "eval 'emrg server stop'", + 'sh -c "$(which emrg) server stop"', + # a chain, and the same chain across a newline + "echo done; emrg server stop", + "echo done\nemrg server stop", + "pkill -f emrgd && ls", +] + +#: Rows that merely *mention* the rule, or signal something that is not the +#: daemon. Each stays allowed — the #1513 lesson is that over-blocking a mention +#: is a defect too (`echo sh "patch /etc/hosts"` had to be fixed, not kept). +NOT_THE_ACT = [ + "git log --grep emrg", + "git -C /Users/x/.emrg/evolution/emrg log --grep restart", + "emrg --help", + "emrg --help | grep stop", + "emrg dev", + "emrg update", + "ls emrg", + "cat emrg/tools/bash_tool.py", + "grep -rn stop 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 + "pkill -f firefox", + "kill 12345", + "killall python", + # a string handed over by a program that cannot run it: `printf` prints its + # argument, so the act is not in it. This is where `env -S "…"` used to sit + # (it *does* run the string, and is now refused on both readers). + 'printf %s "emrg server stop"', +] + + +@pytest.mark.parametrize("cmd", THE_ACT) +def test_the_act_is_refused_at_both_checked_tiers(cmd): + for mode in ("read-only", "workspace-write"): + allowed, reason, _enforcement = _check_sandbox(cmd, mode, "/tmp") + assert allowed is False, f"{cmd!r} was allowed at {mode}" + assert "emrg" in (reason or "") + + +@pytest.mark.parametrize("cmd", NOT_THE_ACT) +def test_a_mention_is_not_the_act(cmd): + for mode in ("read-only", "workspace-write"): + allowed, reason, _enforcement = _check_sandbox(cmd, mode, "/tmp") + assert allowed is True, f"{cmd!r} was refused at {mode}: {reason}" + assert _stops_or_restarts_the_daemon(cmd) is None + + +def test_full_access_is_untouched(): + """The tier that checks nothing still checks nothing — the host's rule there.""" + allowed, _reason, enforcement = _check_sandbox("emrg server stop", "danger-full-access") + assert allowed is True + assert enforcement == "full" + + +def test_the_refusal_names_the_rule_and_a_way_out(): + allowed, reason, _enforcement = _check_sandbox("emrg server restart", "workspace-write") + assert allowed is False + assert "red line" in reason + assert "#1324" in reason + assert "emrg resume" in reason + + +def test_the_classifier_names_the_spelling_it_saw(): + """The message quotes the act, not a truncation of it.""" + assert _stops_or_restarts_the_daemon("emrg --verbose server stop") == ( + "emrg --verbose server stop" + ) + assert _stops_or_restarts_the_daemon("emrg stop --skip-gui") == "emrg stop" + assert _stops_or_restarts_the_daemon("pkill -f emrg.server") == "pkill emrg.server" + + +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 + assert _stops_or_restarts_the_daemon("echo stop emrg") is None + + +def test_a_data_only_shell_word_is_read_by_the_payload_walk_not_by_this_rule(): + """`echo sh "emrg server stop"` prints a string, so the act is not in it. + + Measured rather than asserted from memory, because the answer differs between + two trees: `_nested_command_texts` reads a shell *word*'s payload wherever + the word stands on this tree, and the position gate of issue #1513 (PR #1515) + narrows that. This rule inherits whichever reading the walk holds — it adds + no over-block of its own, which is the claim worth pinning. + """ + cmd = 'echo sh "emrg server stop"' + read = _nested_command_texts(_tokenize_command(cmd)) + if not read: + assert _stops_or_restarts_the_daemon(cmd) is None + else: + assert read == ["emrg server stop"] + assert _stops_or_restarts_the_daemon(cmd) == "emrg server stop" + + +# ── the two readers of one act must not drift ─────────────────────────────── +# +# `tests/conftest.py` classifies the act on an **argv** (a spawned child), and +# the product classifies it on a **command line** (the bash tool). They are +# different inputs to one rule, and the defect this guards against is the one +# this project has paid for repeatedly: two readers of one rule that must change +# together, where the second silently stops firing. The assertion is deliberately +# one-way — argv side refuses ⇒ the command-line side refuses — because the +# command-line side reaches further (unresolved wrappers, a signaller's operand). + +_ARGV_ROWS = [ + ["emrg", "server", "stop"], + ["/usr/local/bin/emrg", "server", "restart"], + ["env", "emrg", "stop"], + ["nohup", "emrg", "stop"], + ["timeout", "5", "emrg", "server", "restart"], + ["uv", "run", "emrg", "server", "stop"], + ["python", "-m", "emrg", "server", "stop"], + # `env -S` hands over one string that it splits and execs: read by the argv + # side since this revision, and by the command-line side for the same reason + ["env", "-S", "emrg server stop"], + ["env", "--split-string", "emrg server stop"], + ["nice", "-n", "5", "pkill", "-f", "emrg.server"], + ["sh", "-c", "emrg server stop"], + ["sh", "-c", "(emrg server restart)"], + ["sh", "-c", "$(which emrg) server stop"], + ["sh", "-c", "'emrg' server stop"], + ["sh", "-c", "`emrg server stop`"], +] + + +@pytest.mark.parametrize("argv", _ARGV_ROWS) +def test_the_two_readers_of_the_act_agree(argv): + refuses = _argv_side_reader() + + if not refuses(argv, shell_parsed=True): + pytest.skip("the argv side does not classify this row") + + line = " ".join(shlex.quote(part) for part in argv) + assert _stops_or_restarts_the_daemon(line) is not None, ( + f"the argv side refuses {argv!r} but the command line {line!r} is allowed" + ) + + +def test_the_env_string_handover_is_read_by_the_act_and_not_by_the_write_walk(): + """Two readings of one string, and the measurement that separates them. + + `env -S ""` splits its value into an argv and execs it, so the act is + *in the string* — the reader that decides "is this the act" has to read it. + The string is not shell text: measured on this host, + `env -S 'printf %s RAN > '` prints `RAN>` and writes **no file**, + because there is no shell to read the `>`. So the write walk must keep + reading it as an opaque argument, and reading it there would refuse a write + that cannot happen — the over-block #1513 removed from this file. + """ + act = 'env -S "emrg server stop"' + assert _stops_or_restarts_the_daemon(act) == "emrg server stop" + for mode in ("read-only", "workspace-write"): + allowed, _reason, _enforcement = _check_sandbox(act, mode, "/tmp") + assert allowed is False + + # the argv side reads the same spelling (a list argv was the gap there) + assert _argv_side_reader()(["env", "-S", "emrg server stop"], shell_parsed=False) + + # and the string stays opaque to the write walk, which is what makes the + # `>` in it harmless: no nested text, no redirect target, no refusal + write_like = "env -S 'printf %s RAN > /tmp/emrg-1324-marker'" + assert _nested_command_texts(_tokenize_command(write_like)) == [] + for mode in ("read-only", "workspace-write"): + allowed, reason, _enforcement = _check_sandbox(write_like, mode, "/tmp") + assert allowed is True, f"{write_like!r} was refused at {mode}: {reason}" diff --git a/tests/test_hermeticity_guard.py b/tests/test_hermeticity_guard.py index 62bf0303..e635f4de 100644 --- a/tests/test_hermeticity_guard.py +++ b/tests/test_hermeticity_guard.py @@ -203,6 +203,14 @@ def test_guard_refuses_spawning_the_stop_or_restart_cli(tmp_path): [str(stubs / "nohup"), str(stubs / "emrg"), "server", "restart"], [str(stubs / "timeout"), "5", str(stubs / "emrg"), "server", "stop"], [str(stubs / "nice"), "-n", "5", str(stubs / "pkill"), "-f", "emrg.server"], + # `env -S ` splits its value into an argv and execs it: the act is + # *in the string*. The shell spelling of this row is below; this is the + # list-argv one, which the guard missed until cycle `cyc20260922-032844` + # (both readers read it now). Every program is still a stub — the string + # names the stub `emrg` by absolute path, so a regression spawns that stub + # and not the host's CLI. + [str(stubs / "env"), "-S", f"{stubs / 'emrg'} server stop"], + [str(stubs / "env"), "--split-string", "emrg server restart"], ): with pytest.raises(AssertionError, match="red-line violation"): subprocess.Popen(argv)