diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index 81d547cd..16d0c8ad 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -141,6 +141,10 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: r"submodule|worktree)\b" ) _GIT_DELETE_RE = re.compile(r"\bgit\s+(?:branch|tag)\s+-[dD]\b") +# `git stash list` / `git stash show` are READ-ONLY stash inspection (reviewing +# WIP) — the mutator regex above would false-positive on them because it matches +# the bare `git stash` token. Exempt a pure stash read (no chain to a mutator). +_GIT_STASH_READ_RE = re.compile(r"\bgit\s+stash\s+(?:list|show)(?:\s|$|\|)") def _extract_write_targets(cmd: str) -> list[str]: @@ -263,8 +267,15 @@ def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[boo # (stash / checkout . / reset --hard / clean) write no file targets # and escaped the target scan (community issue #979). Also blocks # working-tree writers: apply / am / archive / submodule / worktree. + # Pure `git stash list` / `git stash show` (read-only WIP inspection) + # are exempt — but a chain to a mutator (e.g. `&& git stash drop`) + # stays blocked. m = _GIT_MUTATOR_RE.search(cmd) or _GIT_DELETE_RE.search(cmd) - if m: + if m and not ( + _GIT_STASH_READ_RE.search(cmd) + and not re.search(r"\bgit\s+stash\s+(?:drop|pop|clear|apply|push|branch|create)\b", cmd) + and not re.search(r"&&|;", cmd) + ): return False, ( f"read-only sandbox: blocked git mutating command {m.group(0)!r} " "(dirty-tree guard, community issue #979)" diff --git a/tests/test_bash_tool_sandbox.py b/tests/test_bash_tool_sandbox.py index 9290c731..3ae65405 100644 --- a/tests/test_bash_tool_sandbox.py +++ b/tests/test_bash_tool_sandbox.py @@ -106,7 +106,9 @@ def test_check_read_only_allows_dev_null_redirect(): def test_check_read_only_allows_read_commands(): - for cmd in ("ls -la", "git status", "cat file.txt", "pwd", "echo hi"): + for cmd in ("ls -la", "git status", "cat file.txt", "pwd", "echo hi", + "git stash list", "git stash show -p", + "git stash show stash@{0}", "git stash list | grep foo"): allowed, _, _ = _check_sandbox(cmd, "read-only") assert allowed is True, cmd @@ -119,6 +121,10 @@ def test_check_read_only_blocks_git_mutators(): for cmd in ( "git stash", "git stash list && git stash drop", + "git stash push -m wip", + "git stash drop stash@{0}", + "git stash pop", + "git stash clear", "git checkout .", "git checkout -- src/main.py", "git restore .",