@@ -6929,7 +6929,9 @@ def _resolve_explicit_edit_root(raw_root: str, *, workspace_root: Path, extra_ro
69296929 return candidate
69306930 if _linked_worktree_root(workspace_root, candidate) is not None:
69316931 return candidate
6932- if any(candidate == r or candidate.is_relative_to(r) for r in extra_roots):
6932+ # Resolved on both sides: an additional directory reached through a symlink
6933+ # (or the /tmp literal on macOS) contains nothing lexically.
6934+ if any(candidate == r or candidate.is_relative_to(r) for r in (extra.resolve() for extra in extra_roots)):
69336935 return candidate
69346936 return None
69356937
@@ -7851,10 +7853,9 @@ def tool_smart_edit(
78517853 # Confine writes to the workspace root plus any additional directories from
78527854 # Claude Code's additionalDirectories setting or LEMONCROW_ADDITIONAL_DIRS env.
78537855 # Read tools accept any absolute path; writes need explicit opt-in.
7854- # Path("/tmp").resolve() as well as "/tmp": on macOS /tmp is a symlink to
7855- # /private/tmp, and the candidates below are resolved, so the bare literal
7856- # never matched and the /tmp allowance was dead on that platform.
7857- _extra_roots = [*_claude_additional_dirs(repo_root), Path("/tmp"), Path("/tmp").resolve()]
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")]
78587859 if _session_worktree is not None:
78597860 _extra_roots.append(_session_worktree)
78607861
@@ -7880,7 +7881,18 @@ def tool_smart_edit(
78807881 }
78817882 _extra_roots.append(_explicit_root)
78827883 _edit_root = _explicit_root or _session_worktree or repo_root
7883- _allowed_edit_roots = [repo_root, _edit_root, *_extra_roots]
7884+ # Resolved against resolved. Touched paths arrive through
7885+ # _resolve_snapshot_path's .resolve(), while _workspace_root() hands back
7886+ # whatever the env or CLI gave it -- a macOS /tmp or /var path, a home
7887+ # reached through a symlink -- and is_relative_to is purely lexical, so an
7888+ # unresolved root lexically contains none of its own files. Compare the
7889+ # resolved forms; the escape error still prints the caller's own path.
7890+ _allowed_edit_roots = [_candidate.resolve() for _candidate in (repo_root, _edit_root, *_extra_roots)]
7891+ # Every later membership test below takes a RESOLVED path, so it needs the
7892+ # resolved root for the same reason -- under a symlinked workspace an
7893+ # unresolved one silently drops all hook diagnostics and every path the
7894+ # contract review would have read.
7895+ _repo_root_resolved = repo_root.resolve()
78847896
78857897 # A relative path naming an existing file in BOTH the inferred worktree and
78867898 # the workspace root has no right answer: the worktree came from another
@@ -8277,7 +8289,7 @@ def _diag_in_repo_root(d: dict[str, Any], root: Path) -> bool:
82778289 result["diagnostics"] = [
82788290 d
82798291 for d in result["diagnostics"]
8280- if d.get("severity") in ("error", "warning") and _diag_in_repo_root(d, repo_root )
8292+ if d.get("severity") in ("error", "warning") and _diag_in_repo_root(d, _repo_root_resolved )
82818293 ]
82828294 if not result["diagnostics"]:
82838295 result.pop("diagnostics")
@@ -8294,7 +8306,7 @@ def _fmt_diag(d: dict[str, Any], root: Path) -> str:
82948306 msg = d.get("message", "")
82958307 return f"{loc} {code}: {msg}" if code else f"{loc}: {msg}"
82968308
8297- _diag_lines = [_fmt_diag(d, repo_root ) for d in result.pop("diagnostics")]
8309+ _diag_lines = [_fmt_diag(d, _repo_root_resolved ) for d in result.pop("diagnostics")]
82988310 # Cap: a touched file with many pre-existing findings must not dump
82998311 # an unbounded lint report into the edit result.
83008312 if len(_diag_lines) > _EDIT_DIAG_CAP:
@@ -8340,7 +8352,9 @@ def _fmt_diag(d: dict[str, Any], root: Path) -> str:
83408352 result,
83418353 edits,
83428354 repo_root=repo_root,
8343- touched_paths=[str(p.relative_to(repo_root)) for p in paths.values() if p.is_relative_to(repo_root)],
8355+ touched_paths=[
8356+ str(p.relative_to(_repo_root_resolved)) for p in paths.values() if p.is_relative_to(_repo_root_resolved)
8357+ ],
83448358 )
83458359 _phase_contract_ms = int((time.monotonic() - _contract_start) * 1000)
83468360 # Incremental: refresh the shared index for the touched files now, so a
0 commit comments