From bf2e621b8699915cab8b118313a356f73cb83b87 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 25 Aug 2026 19:02:21 +0800 Subject: [PATCH 1/4] emrg: deprecate emrgd.pid/emrgd.port in uninstaller (fixed-port + token ground truth) --- bin/emrg-uninstall | 137 ++++++++++++++++++++---------------- packaging/make-installer.sh | 6 +- 2 files changed, 80 insertions(+), 63 deletions(-) diff --git a/bin/emrg-uninstall b/bin/emrg-uninstall index 55905717..c66d0e28 100755 --- a/bin/emrg-uninstall +++ b/bin/emrg-uninstall @@ -44,15 +44,19 @@ from pathlib import Path EMRG_DIR = Path.home() / ".emrg" LOG_DIR = EMRG_DIR / "logs" GRAVEYARD_DIR = EMRG_DIR / "graveyard" -PORT_FILE = EMRG_DIR / "emrgd.port" -PID_FILE = EMRG_DIR / "emrgd.pid" INSTALL_DIR = EMRG_DIR / "install" +# Rant 2026-08-25T18:46(宿主):emrgd.pid / emrgd.port 已彻底废弃——不写不读。 +# 固定端口(EMRGD_PORT=56031)是 daemon 存活唯一 ground truth(rant +# 2026-08-19T08:05:21);token 只由 emrgd.token 单行承载(rant 2026-08-20T14:32:52)。 +# WHITELIST 里仍保留这两个文件名,仅为卸载时清理老安装的历史残留(不创建不读取)。 + # R101 whitelist — known EMRG files. Anything else in ~/.emrg is user data # and is preserved (listed in the report instead of deleted). # R121: 补全运行时文件 — emrgd.log / emrg-gui.log / gui-window.json 为 daemon/GUI # 运行日志与窗口状态;skills/ 是 daemon 启动时 mkdir 的运行时骨架(非用户数据), # 用户自定义 skills 先入 graveyard 快照再删除(rant 2026-08-05T15:35:17)。 +# emrgd.pid / emrgd.port:历史遗留(已废弃),仅卸载时清理。 WHITELIST = [ "install", "versions", "config.toml", "sessions", "memory", "logs", "projects.yml", "tasks.yml", "rants.jsonl", "saturation", @@ -99,9 +103,16 @@ def stop_gui() -> dict: def stop_daemon() -> dict: - """Step 1b — stop the daemon. Returns {method, ok}.""" + """Step 1b — stop the daemon. Returns {method, ok}. + + Rant 2026-08-25T18:46(宿主):不读 emrgd.pid / emrgd.port(已废弃)。 + 顺序:① ws 协议关闭(固定端口 56031 + emrgd.token 单行 token) + ② 命令行扫描 ``-m emrg.server`` 进程 SIGTERM/taskkill(与 + emrg._stop_all.stop_daemon 同语义,仅剩的可靠身份标记) + """ result = {"method": "none", "ok": False} - # Try protocol shutdown when websockets is importable (PYTHONPATH has lib/). + EMRGD_PORT = 56031 # 固定端口(connect.py EMRGD_PORT,rant 2026-08-19T08:05:21) + token_path = EMRG_DIR / "emrgd.token" try: import asyncio import json as _json @@ -109,23 +120,20 @@ def stop_daemon() -> dict: from websockets.asyncio.client import connect # type: ignore from websockets.exceptions import ConnectionClosed # type: ignore - port = None - token = None - if PORT_FILE.exists(): - lines = PORT_FILE.read_text(encoding="utf-8").splitlines() - if lines: - port = lines[0].strip() - if len(lines) > 1: - token = lines[1].strip() + token = "" + try: + token = token_path.read_text(encoding="utf-8").strip() + except OSError: + pass async def _shutdown() -> bool: - if not port: + if not token: return False try: - ws = await asyncio.wait_for(connect(f"ws://127.0.0.1:{port}", open_timeout=3), timeout=4) + ws = await asyncio.wait_for(connect(f"ws://127.0.0.1:{EMRGD_PORT}", open_timeout=3), timeout=4) try: # Auth handshake (mirrors connect.py connect_to_server). - await ws.send(_json.dumps({"type": "auth", "token": token or ""}, ensure_ascii=False)) + await ws.send(_json.dumps({"type": "auth", "token": token}, ensure_ascii=False)) ack = _json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) if ack.get("type") != "auth_ok": await ws.close() @@ -141,61 +149,68 @@ def stop_daemon() -> dict: except (ConnectionClosed, OSError, asyncio.TimeoutError, _json.JSONDecodeError): return False - if port: + if token: ok = asyncio.run(_shutdown()) if ok: result = {"method": "protocol-shutdown", "ok": True} print(" [1] daemon stopped (protocol shutdown)") return result except Exception: - pass # degrade to pid-based stop + pass # degrade to cmdline-scan stop - # Fallback: SIGTERM via pid file (POSIX) / taskkill (Windows). - pid = None - if PID_FILE.exists(): - try: - pid = int(PID_FILE.read_text(encoding="utf-8").strip()) - except ValueError: - pid = None - if not pid and PORT_FILE.exists(): - # port file second line is the token, not pid — skip - pass - if pid: - try: - if os.name == "nt": - subprocess.run( - ["taskkill", "/PID", str(pid), "/F"], - capture_output=True, timeout=10, - ) - # R121: 轮询确认进程退出(≤5s),避免 pid 文件被并发重建 - for _ in range(35): - chk = subprocess.run( - ["tasklist", "/FI", f"PID eq {pid}"], - capture_output=True, text=True, timeout=5, - ) - if "No tasks" in chk.stdout: - break - time.sleep(0.15) - else: - os.kill(pid, signal.SIGTERM) - for _ in range(20): - try: - os.kill(pid, 0) + # Fallback: kill by command-line identity (``-m emrg.server``) — the only + # reliable marker on Windows (rant 2026-08-17T17:03:38; emrg._stop_all + # same approach). No pid file involved. + import subprocess as _sp + try: + if os.name == "nt": + out = _sp.run( + ["wmic", "process", "where", "name='pythonw.exe'", "get", "processid,commandline"], + capture_output=True, text=True, timeout=10, + ).stdout or "" + # PowerShell CIM fallback for Win11 24H2+ (no wmic). + if not out.strip() or "emrg.server" not in out: + ps_cmd = ("Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match " + "'emrg.server' } | ForEach-Object { $_.ProcessId }") + out = _sp.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, text=True, timeout=10, + ).stdout or "" + pids = [int(p) for p in re.findall(r"\d+", out) if p.strip()] + for pid in pids: + _sp.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, timeout=10) + else: + out = _sp.run( + ["ps", "-axww", "-o", "pid=,command="], + capture_output=True, text=True, timeout=10, + ).stdout or "" + pids = [int(m.group(1)) for m in re.finditer(r"^\s*(\d+)\s+.*-m emrg\.server\b", out, re.M)] + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + if pids: + # Poll ≤5s for the port to close (ground truth). + import socket as _socket + for _ in range(35): + try: + with _socket.create_connection(("127.0.0.1", EMRGD_PORT), timeout=0.3): time.sleep(0.15) - except OSError: - break - result = {"method": "pid-sigterm", "ok": True} - print(f" [1] daemon stopped (pid {pid})") + except OSError: + break + result = {"method": "cmdline-scan", "ok": True} + print(f" [1] daemon stopped (cmdline scan, {len(pids)} pid(s))") return result - except (OSError, subprocess.SubprocessError): - pass - # Fallback: remove stale runtime files. - for f in (PORT_FILE, PID_FILE): - try: - f.unlink() - except FileNotFoundError: - pass - print(" [1] daemon not running (or already stopped); removed stale port/pid files") + except (OSError, subprocess.SubprocessError, _sp.SubprocessError): + pass + # Token cleanup once port is confirmed closed (daemon removes it itself on + # graceful shutdown; a force-killed daemon cannot). + try: + token_path.unlink() + except OSError: + pass + print(" [1] daemon not running (or already stopped)") return result diff --git a/packaging/make-installer.sh b/packaging/make-installer.sh index e689814d..ba6ada33 100755 --- a/packaging/make-installer.sh +++ b/packaging/make-installer.sh @@ -414,8 +414,10 @@ end; // Inno CloseApplications 看不到无窗口的 pythonw daemon(emrgd.cmd → pythonw.exe // -m emrg.server 常驻锁文件),覆盖 ~/.emrg\install 时卡在"停止已有进程"。 // PrepareToInstall 在安装开始前用 runtime 的 python 运行 bin\stop_all.py: -// ws 协议关闭 daemon → emrgd.pid 兜底 → taskkill /F、taskkill EMRG.exe 优雅→/F、 -// CIM 命令行过滤 TUI、install\git\ 前缀连坐强杀 bundled git、verify 残留检查; +// ws 协议关闭 daemon(固定端口 56031 + emrgd.token)→ 命令行扫描 -m emrg.server +// 兜底 → taskkill /F、taskkill EMRG.exe 优雅→/F、CIM 命令行过滤 TUI、 +// install\git\ 前缀连坐强杀 bundled git、verify 残留检查(emrgd.pid 已废弃, +// rant 2026-08-21T16:45:06:固定端口为存活 ground truth); // 有残留 exit 1(脚本打印残留清单)→ 中止安装。干净安装(无旧 install)直接跳过。 // {app} 是旧版安装目录——不能依赖旧版 emrg 命令(可能无 stop 子命令),所以用 // 单文件脚本 + runtime python({app}\bin\python-dist\python.exe,R90 布局, From da24f61b1eca83d16dc7b06f13390d3b3898d4c0 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 25 Aug 2026 19:16:34 +0800 Subject: [PATCH 2/4] =?UTF-8?q?emrg:=20structural=20dirty-tree=20guard=20?= =?UTF-8?q?=E2=80=94=20dirty=20source=20dir=20forces=20read-only=20sandbox?= =?UTF-8?q?=20+=20git=20mutators=20blocked=20(community=20issue=20#979)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 2 +- emrg/server/scheduler.py | 64 ++++++++++++++++++++++++++++- emrg/tools/bash_tool.py | 29 +++++++++++++- tests/test_bash_tool_sandbox.py | 69 ++++++++++++++++++++++++++++++++ tests/test_scheduler.py | 71 +++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 4 deletions(-) diff --git a/Agent.md b/Agent.md index 060bef6d..f81ac641 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1064) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1072) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 1bce8b21..65680a69 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -336,6 +336,65 @@ def _resolve_sandbox(config: dict, explicit: str | None) -> str: return cand return "workspace-write" + @staticmethod + def _is_dirty_tree_sync(source_dir: str) -> bool: + """Sync core of the dirty-tree probe (community issue #979). + + Local ``subprocess`` import on purpose: the module must keep its + no-subprocess attribute invariant (rant 2026-08-19T14:20:52 — the + self-heal git machinery was deleted). Run via ``asyncio.to_thread`` + so the event loop is never blocked by the git call. + + Fail-open by design: a non-git source dir, or a failing git call, + returns False — there is no uncommitted state to protect, and the + guard itself must never block a cycle. + """ + import subprocess as _sp # noqa: PLC0415 — local import keeps the module invariant + + if not os.path.isdir(os.path.join(source_dir, ".git")): + return False + try: + out = _sp.run( + ["git", "-C", source_dir, "status", "--porcelain"], + capture_output=True, text=True, timeout=10, + ) + except (OSError, _sp.SubprocessError): + return False + return bool(out.stdout.strip()) if out.returncode == 0 else False + + async def _effective_sandbox(self, dirty: bool | None = None) -> str: + """Per-cycle effective bash sandbox tier for the task message. + + Structural dirty-tree guard (community issue #979 — heinrichneb's + "audited override" pattern: inconvenient by default, possible on + explicit human override, every exception is a receipt). When the + source dir has uncommitted changes the cycle runs read-only + regardless of configuration — the host's live edits are out of + reach structurally, not by prompt aspiration. A human may override + with the env var ``EMRG_TASK_DIRTY_OVERRIDE`` (comma-separated task + names, or ``*`` for all); every override is logged as a receipt. + """ + if dirty is None: + dirty = await asyncio.to_thread( + self._is_dirty_tree_sync, str(self._source_dir) + ) + if not dirty: + return self._sandbox + override = os.environ.get("EMRG_TASK_DIRTY_OVERRIDE", "") + names = [n.strip() for n in override.split(",") if n.strip()] + if "*" in names or self.name in names: + self._logger.warning( + "TaskHandler[%s]: dirty tree + EMRG_TASK_DIRTY_OVERRIDE — " + "read-only guard overridden (sandbox=%s, audited receipt)", + self.name, self._sandbox, + ) + return self._sandbox + self._logger.warning( + "TaskHandler[%s]: dirty working tree — cycle forced read-only " + "(structural guard, community issue #979)", self.name, + ) + return "read-only" + # ── Saturation state (restored from disk across daemon restarts) ── def _load_saturation_state(self) -> bool: @@ -895,7 +954,10 @@ async def _run_evolution_cycle(self) -> None: "prompt": prompt, "stream": True, "timestamp": cycle_time.isoformat(), - "sandbox": self._sandbox, + # Structural dirty-tree guard (community issue #979): effective + # sandbox per cycle — dirty tree forces read-only unless a + # human set EMRG_TASK_DIRTY_OVERRIDE (audited receipt). + "sandbox": await self._effective_sandbox(), }, ensure_ascii=False, ) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index 60113def..c71ceeeb 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -99,8 +99,11 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: # Three tiers (default danger-full-access = current, un-sandboxed behavior): # danger-full-access — no checks at all (existing behavior) # read-only — no writes allowed: destructive commands (rm -r / -# rmdir / mv / cp -r) and shell redirects (> / >>) -# to any non-/dev/null target are blocked +# rmdir / mv / cp -r), git mutating commands (stash / +# checkout / restore / clean / reset / commit / push / +# pull / merge / rebase — community issue #979) and +# shell redirects (> / >>) to any non-/dev/null target +# are blocked # workspace-write — writes inside the workspace root (and the OS temp # area) are allowed; destructive writes to protected # daemon state files and to absolute paths outside @@ -125,6 +128,19 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: "~/.emrg/rants.jsonl", ) +# Git mutating commands — blocked under read-only (community issue #979, +# heinrichneb dev.to comment on the 2026-08-20 data-loss postmortem): the +# incident's actual killers (`git stash`, `git checkout .`, `git reset --hard`, +# `git clean`) were NOT caught by the rm/rmdir/mv/cp checks. Under read-only +# these must be structurally impossible, not merely discouraged by a prompt +# rule — "rules can regress; topology can't". Read-only git reads (status / +# fetch / log / diff / remote) stay allowed. +_GIT_MUTATOR_RE = re.compile( + r"\bgit\s+(?:stash|checkout|restore|clean|reset|commit|push|pull|merge|" + r"rebase|cherry-pick|cherry_pick|revert|rm|mv|switch)\b" +) +_GIT_DELETE_RE = re.compile(r"\bgit\s+(?:branch|tag)\s+-[dD]\b") + def _extract_write_targets(cmd: str) -> list[str]: """Heuristic extraction of write targets from a command line. @@ -215,6 +231,15 @@ def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[boo return False, ( f"read-only sandbox: blocked destructive write targeting {t!r}" ), "partial" + # Git mutators are blocked too — the 2026-08-20 data-loss commands + # (stash / checkout . / reset --hard / clean) write no file targets + # and escaped the target scan (community issue #979). + m = _GIT_MUTATOR_RE.search(cmd) or _GIT_DELETE_RE.search(cmd) + if m: + return False, ( + f"read-only sandbox: blocked git mutating command {m.group(0)!r} " + "(dirty-tree guard, community issue #979)" + ), "partial" return True, None, "partial" # workspace-write diff --git a/tests/test_bash_tool_sandbox.py b/tests/test_bash_tool_sandbox.py index ab1149a8..66ddc7f4 100644 --- a/tests/test_bash_tool_sandbox.py +++ b/tests/test_bash_tool_sandbox.py @@ -111,6 +111,75 @@ def test_check_read_only_allows_read_commands(): assert allowed is True, cmd +def test_check_read_only_blocks_git_mutators(): + """Community issue #979: read-only must block git mutating commands — + the 2026-08-20 data-loss killers (stash / checkout . / reset --hard / + clean) escaped the rm/mv/cp target scan. Under read-only they must be + structurally impossible, not merely discouraged by a prompt rule.""" + for cmd in ( + "git stash", + "git stash list && git stash drop", + "git checkout .", + "git checkout -- src/main.py", + "git restore .", + "git clean -fd", + "git reset --hard", + "git reset --mixed HEAD~1", + "git commit -m 'wip'", + "git push origin master", + "git pull --rebase", + "git merge master", + "git rebase master", + "git cherry-pick abc123", + "git revert abc123", + "git rm foo.py", + "git switch feature/x", + "git branch -d old", + "git branch -D old", + "git tag -d v1", + ): + allowed, reason, enforcement = _check_sandbox(cmd, "read-only") + assert allowed is False, f"{cmd!r} should be blocked" + assert "read-only sandbox" in reason, cmd + assert "git" in reason, cmd + assert enforcement == "partial" + + +def test_check_read_only_blocks_git_mv(): + """`git mv a b` is blocked by the write-target scan (mv destination) — + the git-mutator reason isn't required, the block is what matters.""" + allowed, reason, _ = _check_sandbox("git mv a b", "read-only") + assert allowed is False + assert "read-only sandbox" in reason + + +def test_check_read_only_allows_git_reads(): + """Read-only keeps read-only git reads available — the read-only cycle + still needs status/fetch/log/diff for scanning and review.""" + for cmd in ( + "git status --short --branch", + "git fetch origin master", + "git log --oneline -3", + "git diff", + "git diff --cached", + "git show HEAD --stat", + "git branch -a", + "git remote -v", + "git rev-parse --abbrev-ref HEAD", + ): + allowed, reason, _ = _check_sandbox(cmd, "read-only") + assert allowed is True, f"{cmd!r} should be allowed (got {reason!r})" + + +def test_check_workspace_write_allows_git_mutators(): + """workspace-write is the normal working tier — tasks must still be able + to commit/push there. Only read-only blocks git mutation.""" + for cmd in ("git stash", "git checkout .", "git reset --hard", + "git commit -m x", "git push origin master"): + allowed, _, _ = _check_sandbox(cmd, "workspace-write") + assert allowed is True, cmd + + # ── _check_sandbox — workspace-write ────────────────────────────────────── def test_check_workspace_write_allows_relative_writes(): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index b9a2a68c..cf02c6f0 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -4,6 +4,7 @@ import asyncio import logging +import os import subprocess import tempfile from pathlib import Path @@ -2476,3 +2477,73 @@ def test_sandbox_resolution_unified_default_rule(): # invalid values fall through to the default assert TaskHandler._resolve_sandbox({"sandbox": "bogus"}, None) == "workspace-write" assert TaskHandler._resolve_sandbox({}, "bogus") == "workspace-write" + + +# ── structural dirty-tree guard (community issue #979) ──────────────────── + + +def test_is_dirty_tree_detects_uncommitted_changes(): + """Community issue #979: a git repo with uncommitted changes is detected + via `git status --porcelain`; a clean repo and a non-git dir are not + (fail-open — the guard never blocks a cycle by itself).""" + with tempfile.TemporaryDirectory() as d: + subprocess.run(["git", "init", d], capture_output=True, timeout=10) + assert TaskHandler._is_dirty_tree_sync(d) is False # clean repo + (Path(d) / "x.txt").write_text("hi", encoding="utf-8") + assert TaskHandler._is_dirty_tree_sync(d) is True # untracked → dirty + # non-git dir → fail-open (False) + with tempfile.TemporaryDirectory() as d: + assert TaskHandler._is_dirty_tree_sync(d) is False + + +def test_dirty_tree_forces_read_only_structural_guard(): + """Community issue #979: a dirty source tree forces the cycle's effective + sandbox to read-only regardless of configuration — topology over rules.""" + handler = TaskHandler( + name="emrg-task", config={"project": "emrg"}, interval=60, + identity=InstanceIdentity(), + ) + assert handler._sandbox == "workspace-write" # configured default + assert asyncio.run(handler._effective_sandbox(dirty=True)) == "read-only" + # configured read-only stays read-only (no weakening) + handler2 = TaskHandler( + name="ro-task", config={}, interval=60, identity=InstanceIdentity(), + sandbox="read-only", + ) + assert asyncio.run(handler2._effective_sandbox(dirty=True)) == "read-only" + + +def test_dirty_tree_override_env_audited_receipt(): + """Community issue #979: EMRG_TASK_DIRTY_OVERRIDE (comma-separated task + names, or *) lets a human lift the guard — every exception is logged as a + receipt. The override must name THIS task (or *) to apply.""" + handler = TaskHandler( + name="emrg-task", config={"project": "emrg"}, interval=60, + identity=InstanceIdentity(), + ) + old = os.environ.get("EMRG_TASK_DIRTY_OVERRIDE") + try: + # task named in the override → configured tier restored + os.environ["EMRG_TASK_DIRTY_OVERRIDE"] = "other-task,emrg-task" + assert asyncio.run(handler._effective_sandbox(dirty=True)) == "workspace-write" + # wildcard → configured tier restored + os.environ["EMRG_TASK_DIRTY_OVERRIDE"] = "*" + assert asyncio.run(handler._effective_sandbox(dirty=True)) == "workspace-write" + # override for a different task → guard still applies + os.environ["EMRG_TASK_DIRTY_OVERRIDE"] = "other-task" + assert asyncio.run(handler._effective_sandbox(dirty=True)) == "read-only" + finally: + if old is None: + os.environ.pop("EMRG_TASK_DIRTY_OVERRIDE", None) + else: + os.environ["EMRG_TASK_DIRTY_OVERRIDE"] = old + + +def test_clean_tree_keeps_configured_sandbox(): + """Community issue #979: a clean tree leaves the configured tier intact — + no behavior change for the normal case.""" + handler = TaskHandler( + name="emrg-task", config={"project": "emrg"}, interval=60, + identity=InstanceIdentity(), + ) + assert asyncio.run(handler._effective_sandbox(dirty=False)) == "workspace-write" From 6f06e4b3a739e4af4fc1760b1446081c6910916a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 25 Aug 2026 19:28:33 +0800 Subject: [PATCH 3/4] =?UTF-8?q?emrg:=20extend=20read-only=20guard=20to=20w?= =?UTF-8?q?rite/edit=20tools=20=E2=80=94=20no=20file=20mutation=20in=20dir?= =?UTF-8?q?ty=20source=20tree=20(community=20issue=20#979)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 2 +- emrg/server/daemon.py | 6 +++++ emrg/tools/bash_tool.py | 27 ++++++++++++++++++++ emrg/tools/edit_tool.py | 10 ++++++++ emrg/tools/write_tool.py | 10 ++++++++ tests/test_edit_tool.py | 53 ++++++++++++++++++++++++++++++++++++++++ tests/test_write_tool.py | 51 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index f81ac641..84ae476c 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1072) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1078) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index bede0624..58ac37b2 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2734,6 +2734,12 @@ async def _run_tool_loop( # agent cannot choose it per call. if tc_name == "bash" and req.sandbox: args["sandbox"] = req.sandbox + # write/edit get the tier + workspace boundary too + # (community issue #979): under read-only the tools must + # not clobber the host's tree — workspace = session cwd. + elif tc_name in ("write", "edit") and req.sandbox: + args["sandbox"] = req.sandbox + args["workspace"] = str(session.cwd) # Execute tool = self.tools.get(tc_name) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index c71ceeeb..38efa440 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -204,6 +204,33 @@ def _is_within(path: str, root: str) -> bool: return False +def check_read_only_file_write(file_path: str, workspace: str | None = None) -> str | None: + """Read-only sandbox check for the write/edit tools (community issue #979). + + Returns a block reason when the target file is inside the task's workspace + (the host's working tree — protected by the structural dirty-tree guard) or + is a protected daemon state file; returns None when allowed. + + Writes OUTSIDE the workspace (memory dir, logs, OS temp) stay allowed so a + read-only cycle can still record state and write its own artifacts — the + guard protects the host's uncommitted work, not the agent's own scratch + space. Mirrors the bash tool's read-only semantics for file tools. + """ + path = os.path.realpath(os.path.expanduser(file_path)) + if workspace: + ws = os.path.realpath(os.path.expanduser(workspace)) + if _is_within(path, ws): + return ( + f"read-only sandbox: blocked file write inside workspace {path!r} " + "(dirty-tree guard, community issue #979)" + ) + if path in _protected_paths(): + return ( + f"read-only sandbox: blocked write to protected daemon file {path!r}" + ) + return None + + def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]: """Static sandbox check for a bash command (rant 2026-08-20T15:46:50). diff --git a/emrg/tools/edit_tool.py b/emrg/tools/edit_tool.py index 7a1155f4..7a1da75f 100644 --- a/emrg/tools/edit_tool.py +++ b/emrg/tools/edit_tool.py @@ -7,6 +7,7 @@ from emrg.server.tool_types import ToolDefinition, ToolResult from emrg.tools.base import ToolExecutor +from emrg.tools.bash_tool import check_read_only_file_write logger = logging.getLogger(__name__) @@ -77,6 +78,15 @@ async def execute(self, arguments: dict) -> ToolResult: path = Path(file_path).expanduser().resolve() + # Read-only sandbox (community issue #979): the edit tool must not + # modify the host's uncommitted work in the task source tree when the + # dirty-tree guard forced read-only. Workspace boundary injected by the + # daemon (session cwd); None in non-daemon use → fail-open. + if arguments.get("sandbox") == "read-only": + reason = check_read_only_file_write(str(path), arguments.get("workspace")) + if reason: + return ToolResult(name="edit", content=reason, error=True) + if not path.exists(): return ToolResult( name="edit", content=f"Error: file not found: {path}", error=True diff --git a/emrg/tools/write_tool.py b/emrg/tools/write_tool.py index 535acbe8..5082476a 100644 --- a/emrg/tools/write_tool.py +++ b/emrg/tools/write_tool.py @@ -7,6 +7,7 @@ from emrg.server.tool_types import ToolDefinition, ToolResult from emrg.tools.base import ToolExecutor +from emrg.tools.bash_tool import check_read_only_file_write logger = logging.getLogger(__name__) @@ -62,6 +63,15 @@ async def execute(self, arguments: dict) -> ToolResult: path = Path(file_path).expanduser().resolve() + # Read-only sandbox (community issue #979): the write tool must not + # clobber the host's uncommitted work in the task source tree when the + # dirty-tree guard forced read-only. Workspace boundary injected by the + # daemon (session cwd); None in non-daemon use → fail-open. + if arguments.get("sandbox") == "read-only": + reason = check_read_only_file_write(str(path), arguments.get("workspace")) + if reason: + return ToolResult(name="write", content=reason, error=True) + try: path.parent.mkdir(parents=True, exist_ok=True) except OSError as e: diff --git a/tests/test_edit_tool.py b/tests/test_edit_tool.py index 0db34252..f30d0057 100644 --- a/tests/test_edit_tool.py +++ b/tests/test_edit_tool.py @@ -110,3 +110,56 @@ def test_edit_is_directory(temp_file): })) assert result.error assert "is a directory" in result.content + + +# ── read-only sandbox (community issue #979) ────────────────────────────── + + +def test_edit_read_only_blocks_inside_workspace(temp_file): + """Issue #979 follow-up: the edit tool must not modify the host's tree + when the dirty-tree guard forced read-only.""" + tool = EditTool() + workspace = temp_file.parent + original = temp_file.read_text() + result = _run(tool.execute({ + "file_path": str(temp_file), + "old_string": "hello world", + "new_string": "changed", + "sandbox": "read-only", + "workspace": str(workspace), + })) + assert result.error + assert "read-only sandbox" in result.content + assert temp_file.read_text() == original # untouched + + +def test_edit_read_only_allows_outside_workspace(temp_file): + """Writes outside the workspace (memory dir, logs) stay allowed — a + read-only cycle still records state and writes its own artifacts.""" + import tempfile as _tf + + with _tf.TemporaryDirectory() as d: + target = Path(d) / "doc.md" + target.write_text("keep this line\n", encoding="utf-8") + tool = EditTool() + result = _run(tool.execute({ + "file_path": str(target), + "old_string": "keep", + "new_string": "edited", + "sandbox": "read-only", + "workspace": str(temp_file.parent), # different boundary + })) + assert not result.error + assert "edited" in target.read_text() + + +def test_edit_no_sandbox_unchanged(temp_file): + """Normal use (no sandbox) keeps editing — no behavior change.""" + tool = EditTool() + result = _run(tool.execute({ + "file_path": str(temp_file), + "old_string": "foo bar", + "new_string": "baz qux", + })) + assert not result.error + assert "baz qux" in temp_file.read_text() diff --git a/tests/test_write_tool.py b/tests/test_write_tool.py index 801f3db6..fa5e7a8d 100644 --- a/tests/test_write_tool.py +++ b/tests/test_write_tool.py @@ -103,3 +103,54 @@ def test_write_content_too_large(temp_dir, monkeypatch): assert result.error assert "too large" in result.content assert not filepath.exists() + + +# ── read-only sandbox (community issue #979) ────────────────────────────── + + +def test_write_read_only_blocks_inside_workspace(temp_dir): + """Issue #979 follow-up: the write tool must not clobber the host's tree + when the dirty-tree guard forced read-only — writes inside the workspace + (task source dir) are blocked.""" + tool = WriteTool() + workspace = temp_dir / "ws" + workspace.mkdir() + target = workspace / "host-file.txt" + result = _run(tool.execute({ + "file_path": str(target), + "content": "should not be written", + "sandbox": "read-only", + "workspace": str(workspace), + })) + assert result.error + assert "read-only sandbox" in result.content + assert not target.exists() + + +def test_write_read_only_allows_outside_workspace(temp_dir): + """Writes outside the workspace (memory dir, logs) stay allowed — a + read-only cycle still records state and writes its own artifacts.""" + tool = WriteTool() + workspace = temp_dir / "ws" + workspace.mkdir() + target = temp_dir / "outside" / "record.md" + result = _run(tool.execute({ + "file_path": str(target), + "content": "# record", + "sandbox": "read-only", + "workspace": str(workspace), + })) + assert not result.error + assert target.exists() + + +def test_write_no_sandbox_unchanged(temp_dir): + """Normal use (no sandbox) keeps writing anywhere — no behavior change.""" + tool = WriteTool() + target = temp_dir / "plain.txt" + result = _run(tool.execute({ + "file_path": str(target), + "content": "hi", + })) + assert not result.error + assert target.read_text() == "hi" From 2d275ab9beea0ed666f7b65e8926c45b6e0b789d Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 25 Aug 2026 19:39:01 +0800 Subject: [PATCH 4/4] emrg: block additional git working-tree mutators under read-only (apply/am/archive/submodule/worktree) --- emrg/tools/bash_tool.py | 6 ++++-- tests/test_bash_tool_sandbox.py | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index 38efa440..81d547cd 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -137,7 +137,8 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: # fetch / log / diff / remote) stay allowed. _GIT_MUTATOR_RE = re.compile( r"\bgit\s+(?:stash|checkout|restore|clean|reset|commit|push|pull|merge|" - r"rebase|cherry-pick|cherry_pick|revert|rm|mv|switch)\b" + r"rebase|cherry-pick|cherry_pick|revert|rm|mv|switch|apply|am|archive|" + r"submodule|worktree)\b" ) _GIT_DELETE_RE = re.compile(r"\bgit\s+(?:branch|tag)\s+-[dD]\b") @@ -260,7 +261,8 @@ def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[boo ), "partial" # Git mutators are blocked too — the 2026-08-20 data-loss commands # (stash / checkout . / reset --hard / clean) write no file targets - # and escaped the target scan (community issue #979). + # and escaped the target scan (community issue #979). Also blocks + # working-tree writers: apply / am / archive / submodule / worktree. m = _GIT_MUTATOR_RE.search(cmd) or _GIT_DELETE_RE.search(cmd) if m: return False, ( diff --git a/tests/test_bash_tool_sandbox.py b/tests/test_bash_tool_sandbox.py index 66ddc7f4..9290c731 100644 --- a/tests/test_bash_tool_sandbox.py +++ b/tests/test_bash_tool_sandbox.py @@ -137,6 +137,12 @@ def test_check_read_only_blocks_git_mutators(): "git branch -d old", "git branch -D old", "git tag -d v1", + # working-tree writers (cycle 20260825-193548) + "git apply patch.diff", + "git am patch-series.mbox", + "git archive --output=tree.tar HEAD", + "git submodule update --init", + "git worktree add ../wt master", ): allowed, reason, enforcement = _check_sandbox(cmd, "read-only") assert allowed is False, f"{cmd!r} should be blocked"