fix(storage): walk the empty-trash guard by descriptor, not by name - #8726
Conversation
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
1 similar comment
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsFINDING -- src/kiro_crew/session_storage.py:1543 -- |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe candidate list contains no candidates (the discovery pass found none). I independently verified the one behavioral change: replacing Key checks:
No grounded (a)/(b)/(c) defect on the changed lines, and no base-rule pattern targets this file. No findings. [OPUS-REVIEWED] 651615d |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix in the module's established descriptor discipline: the last name-based walk on the empty-trash path, with fail-closed semantics provably preserved. The residuals I probed all check out against the base tree: the O(depth)-fd / extreme-depth failure mode of [DESIGN-REVIEWED] 651615d |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All claims verified against the base tree. Composing the final review. First-Principles-Verdict: PASS One reported wedge (#8724), one mechanism-level fix: the sole name-based walk still gating Empty-Trash goes descriptor-based, and every rider preserves the guard's documented fail-closed contract. What this change shipsIntent: make Empty-Trash reclaim macOS batches whose nested paths exceed PATH_MAX, instead of silently wedging forever. FIX.
Counts backing the verdict: [FIRST-PRINCIPLES-REVIEWED] 651615d |
175a6da to
c3d47f1
Compare
|
Round 1 disposition — the unanimous Windows finding taken in
The guard now capability-probes at call time (
The two repointed test doubles are now platform-agnostic per Design Review's suggestion: each patches both First Principles CONCERNS (advisory): acknowledged, no change — the premise note doesn't request an edit. Verification: full |
|
CI status note — the remaining red shard is main's own breakage, not this diff
Everything this PR is responsible for is green on the current head: all four AI lanes cleared (GPT, Opus, Design, First Principles), Windows shards 1/2/4 pass — confirming the restored |
c3d47f1 to
2f2966d
Compare
_unlisted_files still walked with os.walk after kirodotdev#7011 descriptor-hardened the approval scan, chain-open, and removal. On macOS (PATH_MAX 1024) a batch whose nested tree exceeds the limit made the guard's walk die with ENAMETOOLONG, converted into the SessionStorageError the caller maps to SKIP_UNREADABLE - so the batch silently survived every Empty-Trash attempt, forever (kirodotdev#8724). os.fwalk traverses and stats via directory descriptors, so only manifest-relative strings ever use the textual path. fwalk raises the top-level open error instead of routing it to onerror; that error is caught into the same failure list so an unopenable batch stays a reason'd refusal. Fixes kirodotdev#8724.
2f2966d to
651615d
Compare
|
Round 2 disposition — the RecursionError premise is false on every supported Python, but the belt is taken ( GPT blocking (deep batches abort with Taken anyway, as stated hardening rather than a bug fix: the guard's contract is that NO traversal failure escapes as a crash — an escaping exception here turns a refusal-with-reason into an Empty-Trash abort. So the fwalk wrapper now also catches Verification: full |
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review — APPROVE
Filesystem-safety-sensitive change reviewed for TOCTOU and symlink-following. The fix is correct, minimal, and moves the guard in the safe direction.
The bug mechanism, independently reproduced. Built a 3000-level dir_fd-relative tree (6077-byte component path vs Linux PATH_MAX 4096): os.walk routes exactly one ENAMETOOLONG (errno 36) to onerror, which the guard's fail-closed contract converts to SessionStorageError → SKIP_UNREADABLE → the batch survives every Empty-Trash forever. os.fwalk + os.stat(name, dir_fd=rootfd) over the same tree: 0 errors, leaf file correctly classified. The 1024 vs 4096 limit is why this reproduces on macOS and not on Linux CI, but the mechanism is the same and it is now provably gone.
Symlink following: no regression, marginally stronger. os.fwalk's default is follow_symlinks=False (verified on the 3.12 floor this package requires), and CPython's _fwalk does not merely skip links — it lstats the name, opens it, then samestats the opened fd against that lstat before descending, so a directory swapped for a symlink between the two is refused rather than followed. Probed directly: a symlink-to-directory planted in a batch yields no entries from the linked target. That is a TOCTOU check os.walk does not have. Per-entry os.stat(..., follow_symlinks=True) deliberately mirrors the Path.is_file() it replaces, and it is classification-only — a symlink-to-file counts as unlisted (refusal, safe), a symlink-to-dir and a broken link do not, exactly as before. Nothing here unlinks, so nothing follows a link into a destructive operation.
Fail-closed contract preserved end to end. The ignored errno set {ENOENT, ENOTDIR, EBADF, ELOOP} matches CPython's pathlib._ignore_error exactly, so there is zero semantic drift from Path.is_file(); every other errno (notably EACCES, and ENAMETOOLONG itself) joins failures and keeps the batch. fwalk raising the top-level open error instead of routing it through onerror is caught into the same list, so an unopenable batch stays a reason'd refusal rather than an escaping OSError. EBADF in that set is unreachable inside the loop body (fwalk owns rootfd until the generator resumes) — a wash that mirrors the code it replaces, not a new hole.
The dangerous direction is under-reporting, and it is closed twice. I checked all three call sites (_discard_restored_batch, the no-sessions-staged rollback, empty_trash): the return value is used only as if leftovers: / len(leftovers). The Path objects are never handed to a filesystem call, so a >PATH_MAX textual path is pure string work and a mid-walk rename can only produce a false-positive refusal, never a wrong unlink. And removal still re-establishes the same "is anything here unaccounted for" question from the pinned descriptor via _scan_tree(batch_fd, device=…) plus an st_dev/st_ino identity match against the approval, so a guard under-report cannot by itself destroy data.
Scope and process. Two files, one private function plus its two test doubles; no new public surface, config key, or flag. os.walk|os.fwalk now matches exactly one call site in the module, so no unfixed sibling of the root cause is left in scope. Windows keeps the original name-based body byte-for-byte, which is right: fwalk is undefined there and Windows never had this wedge. The repointed doubles patch both mechanisms (raising=False for fwalk), so they still assert the guard's contract on whichever path the platform takes — strengthened, not weakened.
All checks green on 651615df, all four AI lanes PASS, zero unresolved findings. The one residual — GPT's nit that the code comment's "iterative" wording overstates _fwalk's yield from structure — is documentation-accuracy only, and moot because the wrapper catches RecursionError regardless of which reading is right. Squash-merging.
Problem / Motivation
Fixes #8724. On macOS, a Trash batch whose nested tree pushes component paths past
PATH_MAX(1024) becomes permanently un-emptyable:_unlisted_files()— the guard that decides whether an Empty-Trash delete may proceed — still walked by name withos.walkafter #7011 descriptor-hardened the approval scan, chain-open, and removal passes. The walk dies withENAMETOOLONG, which the guard's fail-closed contract converts into theSessionStorageErrorthe caller maps toSKIP_UNREADABLE— so the batch silently survives every Empty-Trash attempt, with only a warning log as a trace.Why it matters
The failure is silent and permanent: the batch shows in Trash, Empty-Trash reports success, and the data is never reclaimed. It also inverts #7011's intent — the passes that act were descriptor-hardened, but the gate that authorizes them remained the one name-based walk, so the platform path limit wedges the whole batch. The #7011 deep-nesting regression test only passes on Linux CI because Linux's
PATH_MAXis 4096; on macOS it fails on main today.What changed
_unlisted_filesnow walks withos.fwalk: traversal and the per-entry stat run via directory descriptors (dir_fd), so only manifest-relative strings ever use the textual path — pure string operations with no length limit. Two semantics deliberately preserved:Path.is_file()exactly:os.stat(name, dir_fd=rootfd)follows symlinks, and the errno setPath.is_file()reports asFalse(ENOENT/ENOTDIR/EBADF/ELOOP) is skipped rather than treated as a scan failure; any otherOSErrorstill joins the failure list and blocks the delete.os.walk,fwalkRAISES when the top itself cannot be opened instead of routing that error throughonerror— the new wrapper catches it into the same failure list, so an unopenable batch remains a reason'd refusal, never an escapingOSError.Two test doubles that monkeypatched
session_storage.os.walkare repointed atfwalkwith matching signatures; they assert the guard's contract (hostile-name escaping, unreadable-scan refusal), not the walk mechanism.Tests
test_a_deeply_nested_batch_does_not_break_the_walk) fails on main withassert ['unreadable_batch'] == []and passes with this change — the exact fail→pass across the fix for the reported mechanism. (It passes on Linux CI either way because Linux'sPATH_MAXis 4096.)test/test_session_storage.py: 229 passed.Pattern harvest
Rule candidate: guards that gate destructive operations must not walk by name — a platform path-length limit converts a traversal error into a permanently wedged state. Use
os.fwalkwhere the platform defines it (CPython: only where{open, stat} <= os.supports_dir_fd, so never on native Windows) and keep the name-based walk as the fallback; notefwalkraises the top-level open error instead of callingonerror, so wrappers must catch it to keep fail-closed semantics.