@@ -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+
68976974def _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)
77297815def 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
0 commit comments