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
59 changes: 48 additions & 11 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -3724,20 +3724,45 @@ async def _run_tool_loop_locked(
def _inject_tool_arguments(tc_name: str, args: dict, session, req: TaskRequest) -> None:
"""Inject the parts of a tool call the model must not choose.

Two rules, and both exist because the value decides a boundary rather
than a preference:

* **the session's cwd is where a filesystem tool works.** Injected
unconditionally, exactly as ``write``/``edit`` receive ``workspace``,
so the value a command runs in and the value it may write under are one
identity rather than two (host ruling 2026-09-21 16:46). It used to be
injected only when the model had not supplied ``workdir`` — and with
that ``and "workdir" not in args`` the model could name the root it was
Three rules. The first two are one fact — the session's cwd is where a
filesystem tool works — split by what the value decides for that tool: a
boundary (pinned) or a preference (a default). The third is the tier.

* **the session's cwd is where a filesystem tool works — for the tools
that run a command.** Injected unconditionally into the shell tools,
exactly as ``write``/``edit`` receive ``workspace``, so the value a
command runs in and the value it may write under are one identity rather
than two (host ruling 2026-09-21 16:46). It used to be injected only
when the model had not supplied ``workdir`` — and with that
``and "workdir" not in args`` the model could name the root it was
trusted in: measured, ``workdir=/Users/<host>`` plus a write to
``.zshrc`` was allowed, because the whole home directory became "the
workspace" (design §2.4). A sandbox that takes its authorization root
from the agent is not a sandbox, so this is one ``and`` fixing the root
cause rather than a new concept.
* **the read-only discovery tools take it as a default.** Neither runs a
command nor writes a file, so their directory decides a preference
rather than a boundary — and both schemas say so ("default: project
root"). ``grep`` has been written that way from the start; ``glob`` was
not, and its pinning was never decided: it shared the shell branch by
adjacency, so when the guard above was dropped from that one shared line
``glob`` lost the ``and "workdir" not in args`` it had alongside
``bash`` (measured 2026-10-11, ``cyc20261011-023918``: a caller that
scoped a glob with ``workdir=<dir>`` was answered about the session
root, in the shape of a real answer — the file names and the "no files
matched" summary both read as true of the wrong tree).
**The caller naming the directory is read in either dialect**, because
the two tools name that one parameter differently — ``glob`` declares
``workdir``, ``grep`` declares ``path`` — and a caller who has just used
one reaches for the other's name. With only the declared spelling
checked here, this default silently overwrote the sibling's: measured
the same cycle, ``grep workdir=<dir>`` was answered about the session
cwd on 12 matches where the caller's two-file directory holds 2, and
``glob path=<dir>`` likewise. The tools accept either spelling
(``_selects``'s sibling, PR #2072), so this rule has to as well, or the
alias is dead in the daemon path — the shape ``tests/
test_shell_tool_mount.py`` now pins by driving this method and then the
tool rather than asserting the argument dict alone.
* **the sandbox tier is the task's, not the agent's** (rant
2026-08-20T15:46:50). ``write``/``edit`` also receive it, together with
the workspace boundary, so that under read-only they cannot clobber the
Expand All @@ -3762,9 +3787,21 @@ def _inject_tool_arguments(tc_name: str, args: dict, session, req: TaskRequest)
:param req: the request carrying the task's configured tier.
"""
cwd = str(session.cwd)
if tc_name in SHELL_TOOL_NAMES or tc_name == "glob":
# Two classes, and what separates them is what the value decides. A shell
# tool runs in this directory and may write under it, so the model cannot
# name it; a read-only discovery tool does neither, so its directory is a
# preference the caller may set and this is only the default.
#
# "The caller set it" is read in **either dialect**: `glob` declares
# `workdir` and `grep` declares `path`, and the tools accept either
# spelling (PR #2072). Checking only the declared key here re-created the
# defect the alias exists to fix — the default landed in the key the tool
# reads first and the caller's sibling-spelled directory was ignored.
if tc_name in SHELL_TOOL_NAMES:
args["workdir"] = cwd
elif tc_name == "glob" and "workdir" not in args and "path" not in args:
args["workdir"] = cwd
elif tc_name == "grep" and "path" not in args:
elif tc_name == "grep" and "path" not in args and "workdir" not in args:
args["path"] = cwd
if tc_name in SHELL_TOOL_NAMES | {"write", "edit"} and req.sandbox:
args["sandbox"] = req.sandbox
Expand Down
14 changes: 12 additions & 2 deletions emrg/tools/glob_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ def definition(self) -> ToolDefinition:
"Working directory for the pattern (default: project root). "
"The hidden/noise skipping is relative to it, so point it at "
"the directory you mean to search — that is how to read "
"inside a skipped one."
"inside a skipped one. `grep` names this same parameter "
"`path`; this tool reads that spelling too, so a call "
"naming either one searches the tree it named."
),
},
"intent": {
Expand All @@ -68,7 +70,15 @@ def definition(self) -> ToolDefinition:

async def execute(self, arguments: dict) -> ToolResult:
pattern = arguments.get("pattern", "")
workdir = arguments.get("workdir") or "."
# `workdir` is this tool's declared spelling; `path` is the name the sibling
# `grep` uses for the same parameter, and a caller who has just used one tool
# reaches for the other's name. Neither used to read the other's, so the call
# fell back to the cwd and answered about a tree the caller never named —
# measured 2026-10-11 (`cyc20261011-015723`) on master `63ee3a54`, where
# `glob path=<tmpdir>` reported `No files matched pattern '*.py' in <this
# checkout>`. The alias convention is `count_argument`'s — `read` reads
# `line_limit` and `limit`.
workdir = arguments.get("workdir") or arguments.get("path") or "."

if not pattern:
return ToolResult(name="glob", content="Error: no pattern provided", error=True)
Expand Down
16 changes: 14 additions & 2 deletions emrg/tools/grep_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ def definition(self) -> ToolDefinition:
"type": "string",
"description": (
"File or directory to search. If a directory, searches "
"recursively. Default: current project root."
"recursively. Default: current project root. `glob` names "
"this same parameter `workdir`; this tool reads that "
"spelling too, so a call naming either one searches the "
"tree it named."
),
},
"glob": {
Expand Down Expand Up @@ -105,7 +108,16 @@ def definition(self) -> ToolDefinition:

async def execute(self, arguments: dict) -> ToolResult:
pattern = arguments.get("pattern", "")
search_path = arguments.get("path") or "."
# The declared spelling first, then the sibling's: `glob` names this same
# parameter `workdir`, and a caller who has just used one tool reaches for the
# other's name. Neither used to read the other's, so the call fell back to the
# cwd and answered about a tree the caller never named — measured 2026-10-11
# (`cyc20261011-015723`) on master `63ee3a54`, where `grep workdir=<tmpdir>`
# over a two-file fixture returned `Found 79 matches … in <this checkout>
# (searched 4796 files)`: a whole-repo scan shaped exactly like an answer. The
# alias convention is `count_argument`'s — `read` reads `line_limit` and
# `limit`.
search_path = arguments.get("path") or arguments.get("workdir") or "."
file_glob = arguments.get("glob")
ignore_case = arguments.get("ignore_case", False)

Expand Down
26 changes: 26 additions & 0 deletions tests/test_glob_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,29 @@ def test_glob_invalid_workdir():
result = _run(tool.execute({"pattern": "**/*.py", "workdir": "/nonexistent"}))
assert result.error
assert "not found" in result.content.lower()


def test_the_siblings_spelling_of_workdir_selects_the_same_tree(temp_cwd):
"""`grep` names this parameter `path`; `glob` reads that spelling too (issue #2071).

Measured 2026-10-11 (`cyc20261011-015723`) on master `63ee3a54`: `glob
path=<a two-file tempdir>` fell back to the cwd and answered `No files matched
pattern '*.py' in <this checkout>` — the tree the caller named was never searched.

The fixture's own file is asserted **before** the comparison, because two readings
that both fell back to the cwd would compare equal as well; an equality that holds
for the wrong reason is not evidence that the alias works.
"""
tool = GlobTool()
declared = _run(tool.execute({"pattern": "**/*.py", "workdir": str(temp_cwd)}))
sibling = _run(tool.execute({"pattern": "**/*.py", "path": str(temp_cwd)}))

assert "test_main.py" in declared.content, declared.content
assert not sibling.error
assert sibling.content == declared.content


def test_the_workdir_description_names_the_spelling_the_reader_accepts():
"""A description is read as the domain its reader enforces, so it names the alias."""
description = GlobTool().definition().parameters["properties"]["workdir"]["description"]
assert "`path`" in description, description
27 changes: 27 additions & 0 deletions tests/test_grep_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,3 +590,30 @@ def test_grep_context_inside_its_domain_still_renders_the_block(tmp_path):
for line in ("beta", ">GAMMA", "delta"):
assert line in result.content
assert "result budget" not in result.content, "a complete search is not a cut one"


def test_the_siblings_spelling_of_path_selects_the_same_tree(temp_cwd):
"""`glob` names this parameter `workdir`; `grep` reads that spelling too (issue #2071).

Measured 2026-10-11 (`cyc20261011-015723`) on master `63ee3a54`: `grep
workdir=<a two-file tempdir>` searched this checkout instead and answered
`Found 79 matches … (searched 4796 files)` — a whole-repo scan shaped exactly like
an answer to the caller's question.

The fixture's own file is asserted **before** the comparison, because two readings
that both fell back to the cwd would compare equal as well; an equality that holds
for the wrong reason is not evidence that the alias works.
"""
tool = GrepTool()
declared = _run(tool.execute({"pattern": "import", "path": str(temp_cwd)}))
sibling = _run(tool.execute({"pattern": "import", "workdir": str(temp_cwd)}))

assert "src/main.py" in declared.content, declared.content
assert not sibling.error
assert sibling.content == declared.content


def test_the_path_description_names_the_spelling_the_reader_accepts():
"""A description is read as the domain its reader enforces, so it names the alias."""
description = GrepTool().definition().parameters["properties"]["path"]["description"]
assert "`workdir`" in description, description
99 changes: 99 additions & 0 deletions tests/test_shell_tool_mount.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,105 @@ def test_grep_receives_the_cwd_only_as_a_default(injected, tmp_path):
assert injected("grep", {"pattern": "x", "path": "/elsewhere"})["path"] == "/elsewhere"


def test_the_discovery_tools_take_the_cwd_as_a_default_too(injected, tmp_path):
"""``glob``'s directory is a preference, exactly as ``grep``'s is.

Both schemas say "default: project root"; neither tool runs a command or
writes a file, so neither directory decides a boundary. ``glob`` shared the
shell branch by adjacency — when the guard was dropped from the one line the
two shared (``8246b691``, whose subject is bash and not glob) it lost the
``and "workdir" not in args`` it had alongside ``bash``, and a caller that
scoped a search with ``workdir="src"`` was answered about the session root
instead, in the shape of a real answer. Diagnosed on 2026-10-02 and never
merged (the branch ``feature/a-glob-keeps-the-directory-it-was-given`` had no
PR); measured again 2026-10-11 (``cyc20261011-023918``) on this tree.
"""
assert injected("glob", {"pattern": "*.py", "workdir": "/elsewhere"})["workdir"] == "/elsewhere"


def test_a_discovery_tool_reads_either_dialect_as_the_caller_naming_a_directory(injected, tmp_path):
"""The sibling spelling is a directory the caller named, not an absent one.

``glob`` declares ``workdir`` and ``grep`` declares ``path`` for the same
parameter, and both tools now read the other's name (PR #2072). The
injection had to learn the same thing: it checked only the **declared** key,
so the default it wrote there was the key the tool reads first and the
caller's sibling-spelled directory was silently replaced — the alias was dead
in the daemon path, on every call, while the tool-level tests (which call the
tool directly) stayed green.
"""
glob_args = injected("glob", {"pattern": "*.py", "path": "/elsewhere"})
assert glob_args.get("workdir") is None, (
"the caller named the directory with the sibling spelling; the default must not land in "
f"the key the tool reads first — got {glob_args!r}"
)
assert glob_args["path"] == "/elsewhere"

grep_args = injected("grep", {"pattern": "x", "workdir": "/elsewhere"})
assert grep_args.get("path") is None, (
f"the caller named the directory with the sibling spelling — got {grep_args!r}"
)
assert grep_args["workdir"] == "/elsewhere"


def test_the_pinned_class_is_the_tools_that_run_a_command(injected, tmp_path):
"""What separates the two classes is what the value decides, not which tool it is.

A shell tool's ``workdir`` is the directory it runs in and may write under, so
the model cannot name it. A discovery tool's is a preference, in either
dialect. Stated as one assertion over both groups so a future tool cannot be
added to either by adjacency — which is exactly how ``glob`` lost the default.
"""
for tool in sorted(SHELL_TOOL_NAMES):
assert injected(tool, {"command": "ls", "workdir": "/named"})["workdir"] == str(tmp_path), (
f"{tool} runs a command, so its workdir is pinned"
)
for tool, declared, sibling in (("glob", "workdir", "path"), ("grep", "path", "workdir")):
assert injected(tool, {declared: "/named"})[declared] == "/named", (
f"{tool} reads only, so the directory it was given stands"
)
assert not injected(tool, {sibling: "/named"}).get(declared), (
f"{tool} reads only, and it reads {sibling} too, so the default must not overwrite it"
)


def test_a_discovery_tool_searches_the_tree_the_caller_named(injected, tmp_path):
"""The seam, driven end to end: injection, then the tool, then where it searched.

Asserting the argument dict alone cannot see this class of defect — the dict
looked right while the tool read another key first — and calling the tool
directly cannot see it either, because the injection is not in that path.
This is the pair of them, which is where the defect lived.
"""
import asyncio

from emrg.tools.glob_tool import GlobTool
from emrg.tools.grep_tool import GrepTool

elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
(elsewhere / "found.py").write_text("needle\n")

session_cwd = tmp_path / "session"
session_cwd.mkdir()
(session_cwd / "other.py").write_text("needle\n")

def run(tool_name: str, args: dict) -> str:
injected(tool_name, args, cwd=session_cwd)
tool = GlobTool() if tool_name == "glob" else GrepTool()
return asyncio.run(tool.execute(args)).content

for tool_name, args in (
("glob", {"pattern": "*.py", "workdir": str(elsewhere)}),
("glob", {"pattern": "*.py", "path": str(elsewhere)}),
("grep", {"pattern": "needle", "path": str(elsewhere)}),
("grep", {"pattern": "needle", "workdir": str(elsewhere)}),
):
content = run(tool_name, args)
assert "found.py" in content, f"{tool_name} {args} searched the wrong tree: {content}"
assert "other.py" not in content, f"{tool_name} {args} searched the session cwd: {content}"


def test_the_tier_comes_from_the_task_and_carries_the_boundary_with_it(injected, tmp_path):
"""``workspace`` is what v2 reads as its authorization root; the model cannot set it."""
args = injected("bash", {"command": "ls", "sandbox": "danger-full-access", "workspace": "/"}, sandbox="read-only")
Expand Down
Loading