Skip to content

Commit a25bf8d

Browse files
fix(mcp): the scratch write roots are named, so the refusal test means the same on Linux
Co-Authored-By: lemoncrow <302591943+lemoncrow-agent[bot]@users.noreply.github.com> LemonCrow-Session: s1
1 parent 7af372f commit a25bf8d

2 files changed

Lines changed: 51 additions & 4 deletions

File tree

src/lemoncrow/gateway/adapters/mcp_server.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6906,6 +6906,14 @@ def _collect_touched_paths(edits: list[dict[str, Any]], *, repo_root: str | Path
69066906
return dict(sorted(paths.items()))
69076907

69086908

6909+
# Scratch directories writes are allowed into besides the workspace and any
6910+
# opted-in additional directory: staging a file before moving it in is ordinary
6911+
# tool work, and refusing it would break callers that predate `root=`. One
6912+
# literal covers macOS's /tmp -> /private/tmp symlink, because every root is
6913+
# resolved before it is compared.
6914+
_SCRATCH_EDIT_ROOTS: tuple[Path, ...] = (Path("/tmp"),)
6915+
6916+
69096917
def _resolve_explicit_edit_root(raw_root: str, *, workspace_root: Path, extra_roots: list[Path]) -> Path | None:
69106918
"""Validate an explicit ``root=`` for edit resolution; None when out of bounds.
69116919

@@ -7853,9 +7861,10 @@ def tool_smart_edit(
78537861
# Confine writes to the workspace root plus any additional directories from
78547862
# Claude Code's additionalDirectories setting or LEMONCROW_ADDITIONAL_DIRS env.
78557863
# Read tools accept any absolute path; writes need explicit opt-in.
7856-
# "/tmp" needs no twin "/private/tmp" entry: _allowed_edit_roots resolves
7857-
# every root before comparing, so one literal covers macOS's symlink.
7858-
_extra_roots = [*_claude_additional_dirs(repo_root), Path("/tmp")]
7864+
# _SCRATCH_EDIT_ROOTS carries the scratch allowance ("/tmp") and needs no
7865+
# twin "/private/tmp" entry: _allowed_edit_roots resolves every root before
7866+
# comparing, so one literal covers macOS's symlink.
7867+
_extra_roots = [*_claude_additional_dirs(repo_root), *_SCRATCH_EDIT_ROOTS]
78597868
if _session_worktree is not None:
78607869
_extra_roots.append(_session_worktree)
78617870

tests/gateway/test_edit_mcp_handler.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1466,8 +1466,16 @@ def test_explicit_root_beats_the_inferred_worktree(workspace: Path, monkeypatch:
14661466
def test_explicit_root_outside_the_workspace_is_refused(
14671467
workspace: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
14681468
) -> None:
1469-
"""A root that is neither this workspace, a worktree of it, nor an allowed dir."""
1469+
"""A root that is neither this workspace, a worktree of it, nor an allowed dir.
1470+
1471+
The scratch allowance is dropped for the duration: pytest's basetemp lives
1472+
under /tmp on Linux, which is a real allowed write root, so a directory
1473+
placed there is legitimately in bounds and the refusal under test would
1474+
never fire. Removing the allowance is what makes `outside` outside on every
1475+
platform -- the companion test below pins the allowance itself.
1476+
"""
14701477
wt = _repo_with_worktree(workspace)
1478+
monkeypatch.setattr(mcp_server, "_SCRATCH_EDIT_ROOTS", ())
14711479
outside = tmp_path.parent / "outside-root"
14721480
outside.mkdir(parents=True, exist_ok=True)
14731481
(outside / "target.txt").write_text("OUTSIDE\n", encoding="utf-8")
@@ -1486,6 +1494,36 @@ def test_explicit_root_outside_the_workspace_is_refused(
14861494
assert (outside / "target.txt").read_text(encoding="utf-8") == "OUTSIDE\n"
14871495

14881496

1497+
def test_explicit_root_under_a_scratch_root_is_allowed(
1498+
workspace: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1499+
) -> None:
1500+
"""A scratch root is a real write root, so naming one as `root` is in bounds.
1501+
1502+
Writes under the scratch allowance predate `root=` -- staging a file before
1503+
moving it in is ordinary tool work -- so validating the argument must not
1504+
quietly narrow what was already writable.
1505+
"""
1506+
wt = _repo_with_worktree(workspace)
1507+
# Outside the workspace, or the workspace root would allow it on its own and
1508+
# the scratch allowance under test would carry nothing.
1509+
scratch = tmp_path.parent / "scratch-root"
1510+
scratch.mkdir(parents=True, exist_ok=True)
1511+
(scratch / "target.txt").write_text("SCRATCH\n", encoding="utf-8")
1512+
monkeypatch.setattr(mcp_server, "_SCRATCH_EDIT_ROOTS", (scratch,))
1513+
monkeypatch.setattr(mcp_server, "_last_session_cwd", str(wt))
1514+
1515+
payload = _edit(
1516+
{
1517+
"post_edit_hooks": False,
1518+
"root": str(scratch),
1519+
"edits": [{"file_path": "target.txt", "old_string": "SCRATCH", "new_string": "EDITED"}],
1520+
}
1521+
)
1522+
1523+
assert "failed" not in payload, payload
1524+
assert (scratch / "target.txt").read_text(encoding="utf-8") == "EDITED\n"
1525+
1526+
14891527
def test_worktree_redirect_is_disclosed_to_the_model(workspace: Path, monkeypatch: pytest.MonkeyPatch) -> None:
14901528
"""The redirect is inferred from the last bash cwd, so it is never silent.
14911529

0 commit comments

Comments
 (0)