Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions emrg/tools/bash_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a path ending in emrg> 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 <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 > <marker>'` 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 "<string>"` 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
Expand Down Expand Up @@ -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:
Expand Down
64 changes: 63 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <string>` (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 <string> …`` 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
Expand Down Expand Up @@ -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 > <marker>'` 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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading