Skip to content

Commit 81d71fa

Browse files
authored
Merge pull request #23 from closedloop-ai/fix/edit-path-resolution
fix(mcp): edit writes land in the checkout the caller meant
2 parents ea31ea9 + a25bf8d commit 81d71fa

3 files changed

Lines changed: 394 additions & 32 deletions

File tree

src/lemoncrow/gateway/adapters/mcp_server.py

Lines changed: 187 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3772,23 +3772,19 @@ def _record_session_cwd(name: str, args: Any) -> None:
37723772
_last_session_cwd = cwd.strip()
37733773

37743774

3775-
def _session_worktree_root(workspace_root: Path) -> Path | None:
3776-
"""The linked git worktree the session is working in, else None.
3775+
def _linked_worktree_root(workspace_root: Path, candidate_dir: Path) -> Path | None:
3776+
"""The linked worktree of ``workspace_root`` that contains ``candidate_dir``, else None.
37773777

37783778
Detected without spawning git: a linked worktree's ``.git`` is a *file*
37793779
holding ``gitdir: <path>``, and for a worktree of THIS repo that path lives
37803780
under ``<workspace_root>/.git/worktrees/``. A normal checkout has ``.git``
37813781
as a directory, which ends the walk immediately.
37823782

3783-
Returns None for every uncertain case -- no recorded cwd, a plain
3784-
directory, a worktree belonging to a different repo -- so resolution falls
3785-
back to the workspace root exactly as before.
3783+
Returns None for every uncertain case -- a plain directory, a worktree
3784+
belonging to a different repo, the workspace root itself.
37863785
"""
3787-
recorded = _last_session_cwd
3788-
if not recorded:
3789-
return None
37903786
try:
3791-
candidate = Path(recorded).expanduser().resolve()
3787+
candidate = candidate_dir.expanduser().resolve()
37923788
if not candidate.is_dir():
37933789
return None
37943790
root = workspace_root.resolve()
@@ -3811,6 +3807,19 @@ def _session_worktree_root(workspace_root: Path) -> Path | None:
38113807
return None
38123808

38133809

3810+
def _session_worktree_root(workspace_root: Path) -> Path | None:
3811+
"""The linked git worktree the session is working in, else None.
3812+
3813+
Returns None for every uncertain case -- no recorded cwd, a plain
3814+
directory, a worktree belonging to a different repo -- so resolution falls
3815+
back to the workspace root exactly as before.
3816+
"""
3817+
recorded = _last_session_cwd
3818+
if not recorded:
3819+
return None
3820+
return _linked_worktree_root(workspace_root, Path(recorded))
3821+
3822+
38143823
# Thread-local slot for passing real tokens_saved from tool handlers to the
38153824
# budget recorder without polluting the LLM-facing response dict.
38163825
# _tool_call_tokens_saved moved to mcp.smart_state (imported/re-exported).
@@ -5530,7 +5539,7 @@ def render_tool_result_text(name: str, result: Any) -> str | None:
55305539
# `resolved_against` rides as a one-liner suffix like vcs_status rather
55315540
# than tripping the JSON fallback -- a redirected edit is still a clean
55325541
# success and should not render as a structured dump.
5533-
base_keys = keys - {"vcs_status", "resolved_against"}
5542+
base_keys = keys - {"vcs_status", "resolved_against", "resolved_against_source"}
55345543
if base_keys <= {"calls_saved"}:
55355544
text = "ok"
55365545
elif base_keys <= {"applied", "calls_saved"}:
@@ -5542,7 +5551,10 @@ def render_tool_result_text(name: str, result: Any) -> str | None:
55425551
if text:
55435552
resolved_against = payload.get("resolved_against")
55445553
if isinstance(resolved_against, str) and resolved_against:
5545-
text = f"{text} | resolved against worktree {resolved_against} (from last bash cwd)"
5554+
if payload.get("resolved_against_source") == "explicit":
5555+
text = f"{text} | resolved against root {resolved_against} (explicit root argument)"
5556+
else:
5557+
text = f"{text} | resolved against worktree {resolved_against} (from last bash cwd)"
55465558
vcs_raw = payload.get("vcs_status")
55475559
vcs = vcs_raw if isinstance(vcs_raw, dict) else {}
55485560
vcs_lines = vcs.get("lines")
@@ -6894,6 +6906,71 @@ def _collect_touched_paths(edits: list[dict[str, Any]], *, repo_root: str | Path
68946906
return dict(sorted(paths.items()))
68956907

68966908

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+
6917+
def _resolve_explicit_edit_root(raw_root: str, *, workspace_root: Path, extra_roots: list[Path]) -> Path | None:
6918+
"""Validate an explicit ``root=`` for edit resolution; None when out of bounds.
6919+
6920+
Accepts the workspace root (and anything under it), a linked worktree of it
6921+
-- worktrees often live outside the repo directory -- and any directory
6922+
already opted in for writes. Anything else is refused rather than honored:
6923+
a root naming a foreign checkout would misdirect writes exactly the way the
6924+
inference this argument exists to override can.
6925+
"""
6926+
candidate = Path(raw_root).expanduser()
6927+
if not candidate.is_absolute():
6928+
candidate = workspace_root / candidate
6929+
try:
6930+
candidate = candidate.resolve()
6931+
if not candidate.is_dir():
6932+
return None
6933+
except OSError:
6934+
return None
6935+
root = workspace_root.resolve()
6936+
if candidate == root or candidate.is_relative_to(root):
6937+
return candidate
6938+
if _linked_worktree_root(workspace_root, candidate) is not None:
6939+
return candidate
6940+
# Resolved on both sides: an additional directory reached through a symlink
6941+
# (or the /tmp literal on macOS) contains nothing lexically.
6942+
if any(candidate == r or candidate.is_relative_to(r) for r in (extra.resolve() for extra in extra_roots)):
6943+
return candidate
6944+
return None
6945+
6946+
6947+
def _ambiguous_relative_edit_paths(
6948+
edits: list[dict[str, Any]], *, inferred_root: Path, workspace_root: Path
6949+
) -> list[tuple[str, list[Path]]]:
6950+
"""Relative edit paths naming an existing file under more than one root.
6951+
6952+
Returns ``(raw_path, [existing candidate, ...])`` for each edit whose
6953+
relative path resolves onto a real file in both the inferred worktree and
6954+
the workspace root. A path that exists in one root is unambiguous, and one
6955+
that exists in neither is a create -- neither is reported.
6956+
"""
6957+
roots = [inferred_root.resolve(), workspace_root.resolve()]
6958+
if roots[0] == roots[1]:
6959+
return []
6960+
ambiguous: list[tuple[str, list[Path]]] = []
6961+
for edit in edits:
6962+
raw = str(edit.get("file_path") or edit.get("path") or "")
6963+
if not raw:
6964+
continue
6965+
candidate = Path(_snapshot_path(raw))
6966+
if candidate.is_absolute():
6967+
continue
6968+
hits = [root / candidate for root in roots if (root / candidate).is_file()]
6969+
if len(hits) > 1:
6970+
ambiguous.append((raw, hits))
6971+
return ambiguous
6972+
6973+
68976974
def _snapshot_paths(paths: dict[str, Path]) -> dict[str, tuple[Path, bool, str | None]]:
68986975
"""Snapshot each file's pre-edit state for rollback.
68996976

@@ -7397,6 +7474,13 @@ def _attach_contract_literal_review(
73977474
},
73987475
},
73997476
},
7477+
"root": {
7478+
"type": "string",
7479+
"description": (
7480+
"Directory relative paths resolve against — the workspace root, one of its "
7481+
"worktrees, or an allowed dir. Beats the inferred worktree."
7482+
),
7483+
},
74007484
},
74017485
}
74027486

@@ -7622,6 +7706,7 @@ def _silence_clean_edit_result(result: dict[str, Any]) -> dict[str, Any]:
76227706
# which is exactly where a wrong worktree inference would hide.
76237707
if "resolved_against" in result:
76247708
silent["resolved_against"] = result["resolved_against"]
7709+
silent["resolved_against_source"] = result.get("resolved_against_source", "inferred")
76257710
return silent
76267711
for key in _EDIT_NOISE_KEYS:
76277712
result.pop(key, None)
@@ -7710,7 +7795,8 @@ def _anchor_snippet(text: str, limit: int = _RETRY_ANCHOR_CHARS) -> str:
77107795
"call, even same-file (ranges use the original snapshot). {path, old, new} "
77117796
"only without a fresh range. Whole or brand-new file: {path, new, "
77127797
"replace:true}. Minified-view line numbers → add :minified "
7713-
"('f.py:minified:L10-L14'). No re-read after success."
7798+
"('f.py:minified:L10-L14'). root='/abs/dir' pins where relative paths "
7799+
"resolve (worktrees). No re-read after success."
77147800
),
77157801
param_aliases={"post_edit_hooks": "hooks"},
77167802
# Policy knobs, not agent choices: accepted by name (tests, power use) but
@@ -7728,6 +7814,7 @@ def _anchor_snippet(text: str, limit: int = _RETRY_ANCHOR_CHARS) -> str:
77287814
)
77297815
def tool_smart_edit(
77307816
edits: list[dict[str, Any]],
7817+
root: str | None = None,
77317818
atomic: bool = True,
77327819
hooks: bool = True,
77337820
post_edit_timeout_ms: int = 30_000,
@@ -7765,22 +7852,84 @@ def tool_smart_edit(
77657852
# `resolved_against` rides on the result, survives the clean-success
77667853
# squelch, and renders as a suffix on the one-liner. Absolute paths are
77677854
# unaffected -- _resolve_snapshot_path only applies a root to relative ones.
7855+
# A caller who knows the checkout passes `root` and skips the guessing
7856+
# entirely; see _resolve_explicit_edit_root.
77687857
_session_worktree = _session_worktree_root(repo_root)
7769-
_edit_root = _session_worktree or repo_root
77707858
edits = [_normalize_edit_aliases(e) for e in edits]
77717859
_require_edits(edits)
77727860

7773-
paths = _collect_touched_paths(edits, repo_root=_edit_root)
77747861
# Confine writes to the workspace root plus any additional directories from
77757862
# Claude Code's additionalDirectories setting or LEMONCROW_ADDITIONAL_DIRS env.
77767863
# Read tools accept any absolute path; writes need explicit opt-in.
7777-
# Path("/tmp").resolve() as well as "/tmp": on macOS /tmp is a symlink to
7778-
# /private/tmp, and the candidates below are resolved, so the bare literal
7779-
# never matched and the /tmp allowance was dead on that platform.
7780-
_extra_roots = [*_claude_additional_dirs(repo_root), Path("/tmp"), Path("/tmp").resolve()]
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]
77817868
if _session_worktree is not None:
77827869
_extra_roots.append(_session_worktree)
7783-
_allowed_edit_roots = [repo_root, _edit_root, *_extra_roots]
7870+
7871+
# An explicit `root` is the caller NAMING the checkout, so it outranks both
7872+
# the inference and the workspace root, and nothing is guessed underneath
7873+
# it. Out of bounds is refused, never quietly ignored.
7874+
_explicit_root: Path | None = None
7875+
if isinstance(root, str) and root.strip():
7876+
_explicit_root = _resolve_explicit_edit_root(root, workspace_root=repo_root, extra_roots=_extra_roots)
7877+
if _explicit_root is None:
7878+
return {
7879+
"failed": [
7880+
{
7881+
"paths": [root],
7882+
"error": (
7883+
f"root {root} is not this workspace ({repo_root}), one of its linked "
7884+
"worktrees, or an allowed additional directory -- pass one of those, or "
7885+
"drop root to resolve against the workspace root"
7886+
),
7887+
}
7888+
],
7889+
"rolled_back": True,
7890+
}
7891+
_extra_roots.append(_explicit_root)
7892+
_edit_root = _explicit_root or _session_worktree or repo_root
7893+
# Resolved against resolved. Touched paths arrive through
7894+
# _resolve_snapshot_path's .resolve(), while _workspace_root() hands back
7895+
# whatever the env or CLI gave it -- a macOS /tmp or /var path, a home
7896+
# reached through a symlink -- and is_relative_to is purely lexical, so an
7897+
# unresolved root lexically contains none of its own files. Compare the
7898+
# resolved forms; the escape error still prints the caller's own path.
7899+
_allowed_edit_roots = [_candidate.resolve() for _candidate in (repo_root, _edit_root, *_extra_roots)]
7900+
# Every later membership test below takes a RESOLVED path, so it needs the
7901+
# resolved root for the same reason -- under a symlinked workspace an
7902+
# unresolved one silently drops all hook diagnostics and every path the
7903+
# contract review would have read.
7904+
_repo_root_resolved = repo_root.resolve()
7905+
7906+
# A relative path naming an existing file in BOTH the inferred worktree and
7907+
# the workspace root has no right answer: the worktree came from another
7908+
# tool's cwd, not from the caller, so choosing one writes a correct change
7909+
# into a checkout nobody named (observed: an edit landing in an unrelated
7910+
# task's worktree). Refuse and make the caller say which. A path that exists
7911+
# under exactly one root, or under neither (a create), still resolves.
7912+
if _explicit_root is None and _session_worktree is not None:
7913+
_ambiguous = _ambiguous_relative_edit_paths(edits, inferred_root=_edit_root, workspace_root=repo_root)
7914+
if _ambiguous:
7915+
_collisions = "; ".join(
7916+
f"{raw} exists in " + " and ".join(str(hit) for hit in hits) for raw, hits in _ambiguous
7917+
)
7918+
return {
7919+
"failed": [
7920+
{
7921+
"paths": [raw for raw, _hits in _ambiguous],
7922+
"error": (
7923+
f"ambiguous relative edit path: {_collisions}. The worktree was inferred "
7924+
"from the last bash cwd, not named by you -- pass an absolute path, or "
7925+
"root=<dir>, to say which checkout you mean"
7926+
),
7927+
}
7928+
],
7929+
"rolled_back": True,
7930+
}
7931+
7932+
paths = _collect_touched_paths(edits, repo_root=_edit_root)
77847933

77857934
_escaped_edit_paths = [
77867935
str(_p) for _p in paths.values() if not any(_p == _r or _p.is_relative_to(_r) for _r in _allowed_edit_roots)
@@ -8004,7 +8153,11 @@ def tool_smart_edit(
80048153

80058154
from lemoncrow.pro.capabilities.tool_supervision.rich_edit import apply_rich_edits
80068155

8007-
result = apply_rich_edits(edits, atomic=atomic, repo_root=_edit_root, allowed_roots=_extra_roots)
8156+
# The SAME roots the confinement check above allows, not just the extras:
8157+
# rich_edit._resolve confines to [repo_root=_edit_root, *allowed_roots], so
8158+
# passing only _extra_roots dropped the main checkout and refused every
8159+
# absolute path under it while a worktree was inferred.
8160+
result = apply_rich_edits(edits, atomic=atomic, repo_root=_edit_root, allowed_roots=_allowed_edit_roots)
80088161
_phase_write_ms = int((time.monotonic() - _phase_start) * 1000)
80098162

80108163
# Sync the long-lived engine's index-version cache so the next explore
@@ -8145,7 +8298,7 @@ def _diag_in_repo_root(d: dict[str, Any], root: Path) -> bool:
81458298
result["diagnostics"] = [
81468299
d
81478300
for d in result["diagnostics"]
8148-
if d.get("severity") in ("error", "warning") and _diag_in_repo_root(d, repo_root)
8301+
if d.get("severity") in ("error", "warning") and _diag_in_repo_root(d, _repo_root_resolved)
81498302
]
81508303
if not result["diagnostics"]:
81518304
result.pop("diagnostics")
@@ -8162,7 +8315,7 @@ def _fmt_diag(d: dict[str, Any], root: Path) -> str:
81628315
msg = d.get("message", "")
81638316
return f"{loc} {code}: {msg}" if code else f"{loc}: {msg}"
81648317

8165-
_diag_lines = [_fmt_diag(d, repo_root) for d in result.pop("diagnostics")]
8318+
_diag_lines = [_fmt_diag(d, _repo_root_resolved) for d in result.pop("diagnostics")]
81668319
# Cap: a touched file with many pre-existing findings must not dump
81678320
# an unbounded lint report into the edit result.
81688321
if len(_diag_lines) > _EDIT_DIAG_CAP:
@@ -8208,7 +8361,9 @@ def _fmt_diag(d: dict[str, Any], root: Path) -> str:
82088361
result,
82098362
edits,
82108363
repo_root=repo_root,
8211-
touched_paths=[str(p.relative_to(repo_root)) for p in paths.values() if p.is_relative_to(repo_root)],
8364+
touched_paths=[
8365+
str(p.relative_to(_repo_root_resolved)) for p in paths.values() if p.is_relative_to(_repo_root_resolved)
8366+
],
82128367
)
82138368
_phase_contract_ms = int((time.monotonic() - _contract_start) * 1000)
82148369
# Incremental: refresh the shared index for the touched files now, so a
@@ -8220,10 +8375,16 @@ def _fmt_diag(d: dict[str, Any], root: Path) -> str:
82208375
"hooks": _phase_hooks_ms,
82218376
"contract": _phase_contract_ms,
82228377
}
8223-
# Disclose the worktree redirect. It is an inference, so a wrong one has to
8224-
# be visible in the same breath as the write it misdirected.
8225-
if _session_worktree is not None:
8378+
# Disclose which root the relative paths resolved against, and whether the
8379+
# caller named it: an inference that went wrong has to be visible in the
8380+
# same breath as the write it misdirected, and an explicit root carries the
8381+
# trust an inference does not.
8382+
if _explicit_root is not None:
8383+
result["resolved_against"] = str(_explicit_root)
8384+
result["resolved_against_source"] = "explicit"
8385+
elif _session_worktree is not None:
82268386
result["resolved_against"] = str(_session_worktree)
8387+
result["resolved_against_source"] = "inferred"
82278388
return _silence_clean_edit_result(result)
82288389

82298390

src/lemoncrow/pro/capabilities/tool_supervision/rich_edit.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,11 @@ def _resolve(root: Path, raw_path: str, allowed_roots: list[Path] | None = None)
137137
path = Path(spec.path)
138138
resolved = path if path.is_absolute() else root / path
139139
resolved = resolved.resolve()
140-
roots = [root, *(allowed_roots or [])]
140+
# `resolved` is resolved, so the roots must be too: is_relative_to is
141+
# lexical, and a root carrying a symlink (macOS /tmp, /var, a symlinked
142+
# home) lexically contains none of its own files. The message below still
143+
# names `root` as the caller passed it.
144+
roots = [Path(r).resolve() for r in (root, *(allowed_roots or []))]
141145
if not any(resolved == r or resolved.is_relative_to(r) for r in roots):
142146
raise ValueError(
143147
f"path escape denied: {raw_path} is outside the workspace root {root} — "

0 commit comments

Comments
 (0)