fix(agents): a claude purge never follows a linked dir below its root (Issue #31, Mac review)

A tmp-root entry <key> linking outside made `<key>/<uuid>` look like a plain
directory, and rmtree removed what lay behind the link; projects/<key>,
file-history/ and the other config dirs had no such check either. Each
candidate now keeps the root it was found under (config, projects or tmp),
and is purged only when every component between that root and the leaf is a
real directory and its parent resolves inside the resolved root. A root may
itself be a link (/tmp -> /private/tmp), a leaf link is still removed as the
link alone, and a candidate behind a link is reported as residual instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-16 03:20:55 +00:00
1 parent bd09c6a133
commit 6e0361cf43
2 files changed
+114 -24

No files matched your search

+41 -24
View File
@@ -1,4 +1,4 @@
import os, re, json, glob, time, shutil, subprocess import os, re, json, glob, stat, time, shutil, subprocess
from typing import Optional, Any from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names, _plausible_id from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names, _plausible_id
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
@@ -91,6 +91,23 @@ def _session_file_live(data: dict, pid) -> bool:
return lstart <= started + 2 return lstart <= started + 2
def _inside(root: str, path: str) -> bool:
"""`path` is reached from `root` through real directories only: no
component between the two is a symlink, and its parent resolves inside
the resolved root. The root may be a link itself (/tmp -> /private/tmp,
a linked ~/.claude), and so may `path`: _remove then takes the link alone."""
cur = root
for part in os.path.relpath(os.path.dirname(path), root).split(os.sep):
cur = os.path.join(cur, part)
try:
if not stat.S_ISDIR(os.lstat(cur).st_mode):
return False
except OSError:
return False
real_root = os.path.realpath(root)
return os.path.realpath(os.path.dirname(path)).startswith(real_root.rstrip(os.sep) + os.sep)
def _remove(path: str) -> None: def _remove(path: str) -> None:
try: try:
if os.path.islink(path) or not os.path.isdir(path): if os.path.islink(path) or not os.path.isdir(path):
@@ -267,10 +284,12 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
pass pass
return None return None
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list: def _candidates(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Every existing path that belongs to this session alone (Issue #31). """(root, path) of every existing path that belongs to this session
Shared files that merely mention it (history.jsonl, ~/.claude.json, alone (Issue #31), with the root (config, projects or tmp) it was
backups, paste-cache) are not the session's and are never listed.""" found under. Shared files that merely mention it (history.jsonl,
~/.claude.json, backups, paste-cache) are not the session's and are
never listed."""
# Checked before any path is built: whole directories named by the id # Checked before any path is built: whole directories named by the id
# are removed, so "", "." or ".." would reach file-history/ itself, # are removed, so "", "." or ".." would reach file-history/ itself,
# and "memory" the auto memory. An id that is not a uuid keeps the # and "memory" the auto memory. An id that is not a uuid keeps the
@@ -278,30 +297,35 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
if not _plausible_id(uuid): if not _plausible_id(uuid):
return [] return []
cfg = _config_dir(ctx) cfg = _config_dir(ctx)
paths = [] found = []
for pdir in _projects_dirs(ctx): for pdir in _projects_dirs(ctx):
base = os.path.join(pdir, ctx.ws_key, uuid) base = os.path.join(pdir, ctx.ws_key, uuid)
paths += [p for p in ((base + ".jsonl", base) if _is_uuid(uuid) else (base + ".jsonl",)) found += [(pdir, p) for p in ((base + ".jsonl", base) if _is_uuid(uuid) else (base + ".jsonl",))
if os.path.lexists(p)] if os.path.lexists(p)]
if not _is_uuid(uuid): if not _is_uuid(uuid):
return paths return found
paths += [p for p in (os.path.join(cfg, "file-history", uuid), found += [(cfg, p) for p in (os.path.join(cfg, "file-history", uuid),
os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)] os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)]
job = self._owned_job(uuid, ctx) job = self._owned_job(uuid, ctx)
if job: if job:
paths.append(job) found.append((cfg, job))
for path, pid, data in self._session_files(uuid, ctx): for path, pid, data in self._session_files(uuid, ctx):
if not _session_file_live(data, pid): if not _session_file_live(data, pid):
# Keys before the session file: the file is what ties them to # Keys before the session file: the file is what ties them to
# this session, so it must outlive them. # this session, so it must outlive them.
paths += _key_files(path) + [path] found += [(cfg, p) for p in _key_files(path) + [path]]
for root in _tmp_roots(): for root in _tmp_roots():
try: try:
keys = sorted(os.listdir(root)) keys = sorted(os.listdir(root))
except OSError: except OSError:
continue continue
paths.extend(p for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)) found += [(root, p) for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)]
return paths return found
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
# What lies behind a linked dir below its root is not the session's:
# it is kept, and residual_artifacts reports it.
return [p for root, p in self._candidates(uuid, ctx) if _inside(root, p)]
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool: def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
# Leftovers count without the transcript (a pre-#31 purge left them). # Leftovers count without the transcript (a pre-#31 purge left them).
@@ -342,7 +366,7 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
files with their (masked) key files. projects is not walked deeper; files with their (masked) key files. projects is not walked deeper;
it can be very large.""" it can be very large."""
if not _is_uuid(uuid): if not _is_uuid(uuid):
return self._owned_paths(uuid, ctx) return sorted(p for _, p in self._candidates(uuid, ctx))
cfg = _config_dir(ctx) cfg = _config_dir(ctx)
pdirs = _projects_dirs(ctx) pdirs = _projects_dirs(ctx)
found = set(scan_names(cfg, uuid, skip=pdirs)) found = set(scan_names(cfg, uuid, skip=pdirs))
@@ -360,18 +384,11 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
found.update(os.path.join(kdir, n) for n in os.listdir(kdir) if uuid in n) found.update(os.path.join(kdir, n) for n in os.listdir(kdir) if uuid in n)
except OSError: except OSError:
pass pass
job = self._owned_job(uuid, ctx) # Every candidate, inside its root or behind a link (never purged).
if job: found.update(_label(p) for _, p in self._candidates(uuid, ctx))
found.add(job)
for path, _, _ in self._session_files(uuid, ctx): for path, _, _ in self._session_files(uuid, ctx):
found.add(path) found.add(path)
found.update(_label(k) for k in _key_files(path)) found.update(_label(k) for k in _key_files(path))
for root in _tmp_roots():
try:
tkeys = os.listdir(root)
except OSError:
continue
found.update(p for p in (os.path.join(root, k, uuid) for k in tkeys) if os.path.lexists(p))
return sorted(found) return sorted(found)
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
+73
View File
@@ -290,6 +290,79 @@ def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad):
assert memory.exists() and all(p.exists() for p in named) assert memory.exists() and all(p.exists() for p in named)
def test_claude_purge_never_follows_a_linked_dir_below_a_root(home, tmp_path, conv):
"""A symlinked <key> (tmp, projects) or file-history/ below its root is
not followed: what lies behind it stays and is reported as residual."""
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
key = workspace_key(str(ws))
out = tmp_path / "outside"
victims = [out / "tmp" / conv / "keep.txt", out / "proj" / f"{conv}.jsonl", out / "proj" / conv / "keep.txt",
out / "fh" / conv / "keep.txt"]
for v in victims:
v.parent.mkdir(parents=True, exist_ok=True)
v.write_text("keep")
troot = tmp_path / "ctmp" / f"claude-{UID}"
troot.mkdir(parents=True)
links = [troot / "linked-workspace", cfg / "projects" / key, cfg / "file-history"]
for link, target in zip(links, (out / "tmp", out / "proj", out / "fh")):
link.symlink_to(target)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.purge_artifacts(conv, ctx) == []
assert all(v.exists() for v in victims) and all(link.is_symlink() for link in links)
assert adapter.residual_artifacts(conv, ctx) == sorted(
[str(troot / "linked-workspace" / conv), str(cfg / "projects" / key / f"{conv}.jsonl"),
str(cfg / "projects" / key / conv), str(cfg / "file-history" / conv)])
def test_claude_purge_removes_only_the_link_at_the_leaf(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
key = workspace_key(str(ws))
out = tmp_path / "outside"
keep = [out / "tmp" / "keep.txt", out / "proj" / "keep.txt"]
for k in keep:
k.parent.mkdir(parents=True)
k.write_text("keep")
leaves = [tmp_path / "ctmp" / f"claude-{UID}" / key / conv, home / ".claude" / "projects" / key / conv]
for leaf, target in zip(leaves, (out / "tmp", out / "proj")):
leaf.parent.mkdir(parents=True)
leaf.symlink_to(target)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert set(adapter.purge_artifacts(conv, ctx)) == {str(p) for p in leaves}
assert not any(os.path.lexists(p) for p in leaves)
assert all(k.exists() for k in keep)
assert adapter.residual_artifacts(conv, ctx) == []
def test_claude_roots_may_be_links(home, tmp_path, monkeypatch, conv):
"""A root that is itself a link (/tmp -> /private/tmp on macOS, a linked
~/.claude) is the boundary, not a way out of it."""
ws = tmp_path / "ws"
ws.mkdir()
real_cfg, real_tmp = tmp_path / "real-cfg", tmp_path / "real-tmp"
real_cfg.mkdir()
real_tmp.mkdir()
(tmp_path / "cfg-link").symlink_to(real_cfg)
(tmp_path / "tmp-link").symlink_to(real_tmp)
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg-link"))
monkeypatch.delenv("CLAUDE_PROJECT_DIR")
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(tmp_path / "tmp-link"))
t = _tree(home, ws, conv, tmp_path / "tmp-link", cfg=tmp_path / "cfg-link")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
adapter.purge_artifacts(conv, ctx)
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert adapter.residual_artifacts(conv, ctx) == []
def test_a_non_uuid_id_takes_only_its_transcript_file(home, tmp_path): def test_a_non_uuid_id_takes_only_its_transcript_file(home, tmp_path):
"""The pre-#31 reach, kept for ids that are not uuids (test_a4 purges """The pre-#31 reach, kept for ids that are not uuids (test_a4 purges
"uuid-c"): the `<id>.jsonl` file, never a directory of that name.""" "uuid-c"): the `<id>.jsonl` file, never a directory of that name."""