Skip to content

fix(system): bind the session-Trash delete to descriptors, not names - #7011

Merged
iamwhatever merged 1 commit into
mainfrom
fix/trash-delete-containment-5430
Sep 1, 2026
Merged

fix(system): bind the session-Trash delete to descriptors, not names#7011
iamwhatever merged 1 commit into
mainfrom
fix/trash-delete-containment-5430

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

What is the problem?

Emptying the session Trash removed each staged batch with shutil.rmtree(batch, ignore_errors=True), which resolves the path it is given. The trash lives under the user's own data home, so every ancestor directory is writable by the same account - and by an auto-approved agent shell. An ancestor swapped to a symlink between the check that selected the batch and the removal that walks to it was followed, and the removal landed outside the trash.

There was a second gap in the same path. The set handed to the background worker was a list of batch IDs, and an id is only a name. The mutation lock is released for the handoff, so a directory moved into an approved name was opened by that name and deleted - session data the user was never shown and never approved.

Why this issue matters to the user

Both failures destroy the only copy of session data. A staged batch is not a second copy of anything: move_to_trash MOVES the files, so whatever is inside a batch exists nowhere else. A delete that leaves the trash, or that removes a batch the user did not approve, is unrecoverable.

The window is not theoretically small either. #5195 made emptying a background job, so the gap between "the user approved this set" and "the bytes are gone" went from milliseconds to minutes on a large store - one staged batch can hold tens of thousands of sessions.

How our fix solves it

Symptom: bytes disappear from outside the trash, or a batch the user never saw disappears. Root cause: every step of the removal addressed a NAME that the kernel re-resolved at the moment of use, and consent was recorded against a name rather than against an object. So each step stops resolving a path:

  1. The batch is opened by walking from the filesystem root, one component at a time with O_NOFOLLOW. That flag constrains only the LAST component, which is why opening the batch by path left the whole prefix to be re-resolved. The path walked is the RESOLVED one, so an install whose data home legitimately sits behind a symlinked home directory is not refused.
  2. Each file is removed by (directory fd, name), with the files named by the MANIFEST rather than discovered by os.walk, and the emptied directories removed bottom-up by descriptor - including the batch itself, through its parent's descriptor. Finishing with rmtree(batch) would re-resolve the whole prefix and undo the walk above it. Manifest-driven naming is independently necessary on Windows, where a junction is not a symlink and os.walk descends into one.
  3. The batch's (st_dev, st_ino) travels with its id from staged_targets() into the worker and is re-checked with fstat on the descriptor that was opened - identity_changed if it differs, and the batch is kept. Checked on the descriptor rather than by a second stat of the path, because the fd is the object every removal addresses: a swap after that point cannot reach the data.

Two smaller consequences fell out of naming the files. Progress is now reported per file rather than per batch, which is what the reporting was for. And the manifest is removed LAST, only once the sweep confirms nothing else is left - list_trash() omits a batch with no readable manifest, so removing it while a listed file survived left data on disk the user could neither see nor restore. An entry naming the manifest is refused for the same reason.

Limits, stated rather than hidden:

  • Windows has neither openat nor O_NOFOLLOW, so it keeps the coarse rmtree with its measured byte figure and its post-condition check, and progress there stays per batch. A smoother bar is not worth a weaker delete.
  • The leaf unlink still addresses a NAME, so a final-component swap remains possible: POSIX has no unlink-by-inode, and the stdlib's own _rmtree_safe_fd has the identical property. What changed is that the exposure is now that one component, on a file the manifest lists, instead of the whole path prefix.
  • Two siblings of the same root cause are NOT fixed here: _discard_restored_batch and
    move_to_trash's empty-batch cleanup still remove a trash directory by re-resolved
    path, so the ancestor-swap exposure remains on those two paths. This PR point-fixes
    the empty, which is the one the issue is about and the one that runs minutes after the
    user's click; the other two are a follow-up.
  • A request that could not take a snapshot fails CLOSED, for an explicit batch_ids selection as much as for "everything currently staged", and says so on the job. An earlier revision of this bullet said the explicit case ran without the identity check; that described a proceed-anyway branch this PR deletes, and First Principles review caught the description still promising it. The code is the stricter side, and
    test_a_named_selection_fails_closed_when_the_snapshot_cannot_be_read pins it.

What the user sees

A batch kept for this reason now says so, in the reader's language, and stays in the
Trash where it can still be restored:

Session storage Trash showing a kept batch and the refusal line

Captured from an isolated pod running this branch. The empty-job status and the
session inventory are supplied by the harness so the refusal can be photographed
deterministically; the component, the styling and the locale bundle are the real
build. Before this change the same code path fell through to the generic
kept_unknown sentence.

What tests we did

Nine cases in test/test_session_storage.py, plus the existing coarse-path cases pinned to the rmtree branch. Six mutations were applied to the implementation and each turned the intended test red:

Mutation Test that caught it
O_NOFOLLOW dropped from the dir-open flags linked ancestor DID NOT RAISE; the swapped-component test lost the live file it was protecting
identity comparison neutered swapped-after-selection saw [] instead of ['identity_changed']
unlink by path instead of (dir_fd, name) every-unlink-by-descriptor
manifest-self-entry guard removed manifest-lists-itself
final rmdir replaced by rmtree never-removes-by-path, and the will-not-go refusal
manifest removed before the sweep surviving-file-keeps-the-manifest, manifest-lists-itself

Gates: pytest test/test_session_storage.py test/test_session_storage_api.py gives 245 passed. flake8, isort and mypy are clean on the touched modules; on the check_black_formatting.py question, all four Python files are black-clean and none is in the baseline. Frontend: tsc --noEmit clean, vitest run SessionStorageScreen.test.tsx i18nAllLanguagesEntry.test.ts i18nGateTable.test.ts gives 87 passed, and eslint on the touched file shows only its three pre-existing a11y warnings. The full suite is left to CI.

Round 2, after review. All three GPT blocking findings were legitimate and are fixed
rather than overridden, each with its own mutation-verified test:

  • Interior directories were unpinned. O_NOFOLLOW refuses a link, but a real
    directory RENAMED onto a staged directory's name is not a link, and pinning the batch
    does not cover a rename that happens inside it. The delete now takes one pinned
    traversal before it removes anything, recording every interior directory's inode, and
    admits a component only as that inode on the batch's own device. Verifying against a
    listing re-read at open time does NOT work - it reports the impostor - which the test
    caught before review would have.
  • The manifest could disappear before the final rmdir. It is now renamed to the
    trash root under a debris name and renamed back if the removal does not complete, so
    the batch is never unlistable with data still inside it.
  • The coarse path ignored the expected identity. It now compares it too, by lstat
    before rmtree, with the residual window stated in the code.
Mutation (round 2) Test that caught it
interior inode comparison neutered renamed-into-a-staged-name stops raising; swapped-after-the-scan deletes the live file
manifest unlinked instead of moved aside late-file-leaves-the-batch-restorable
coarse-path comparison neutered coarse-path-also-refuses-a-swapped-batch

Round 3, after review. GPT held the line on two points and was right on both:

  • The interior map was built at DELETE time, so a directory swapped in during the async
    handoff was recorded as legitimate by the very scan meant to catch it. The map now comes
    from staged_targets() under the mutation lock, carried in a BatchIdentity alongside the
    batch's own (st_dev, st_ino), and the delete demands equality in both directions - a
    directory added, removed or replaced since approval is a refusal. A concurrent restore that
    removed a staged directory lands here too, and refusing is right: the approval no longer
    describes the batch.
  • The coarse path checked the identity and then handed the same path to rmtree, which
    re-resolves it. It now RENAMES the batch to .<batch-id>.removing-<random> first, verifies
    the renamed directory, and removes it under that name; a swap before the rename is caught
    and the impostor renamed back, and the name finally removed is unguessable. I did not take
    the suggested fail-closed instead, because the API always supplies an identity, so failing
    closed would make emptying the Trash impossible on Windows - a worse outcome than a window
    an attacker must guess their way into. Flipping it to strict fail-closed is a one-line
    change if the maintainer prefers that trade.
Mutation (round 3) Test that caught it
verify against the delete-time scan instead of the approval interior-swap-during-the-handoff
check the identity before the staging rename instead of after coarse-path-removes-what-it-verified (the impostor is destroyed)

241 targeted tests pass on this head.

Round 4, after review. Two more, both real, and the first one's root cause was the
recursion I had flagged as acceptable stdlib parity:

  • A snapshot failure disabled the check instead of stopping the delete. A deeply
    nested staged tree made the recursive scan raise RecursionError - not OSError, so it
    escaped every handler that turns a failed read into a refusal - and the request handler
    answered a named selection by dispatching it unchecked. Both walks are now iterative
    (one descriptor per level of the current path, so a deep tree fails with EMFILE, which
    IS handled), and the handler fails closed for a named selection exactly as it already
    did for "everything staged": settled job, reason on it, no worker dispatched.
  • The final sweep ignored the approved identities. It enumerated children by name and
    descended into whatever answered, so a directory swapped in after verification had its
    links and empty directories removed. The recursive sweep is gone: directory removal is
    driven by the approved map, deepest first, each reached through a descriptor chain that
    admits only the approved inode - and the directory being removed is itself checked, since
    rmdir addresses a name and a top-level staged directory's parent is the batch. "Is it
    empty" is now a separate fresh scan rather than a side effect of the code deciding what
    to delete.

Writing the second test found a third defect neither review caught: the descriptor cache
from the file phase satisfied the leaf check with the identity of the directory that was
there THEN, so the cache is now dropped before the removal phase and every directory is
re-opened and re-verified.

Mutation (round 4) Test that caught it
verify only the parent chain, not the directory being removed removal-phase-refuses-a-directory-the-approval-never-named
dispatch a named selection without identities when the snapshot fails named-selection-fails-closed

One behaviour change to call out for review: a named selection whose snapshot fails now
deletes nothing where it used to proceed. The test that encoded the old contract is
rewritten rather than deleted, with the reasoning in its docstring. 243 targeted tests pass.

Round 5, after review. The finding was the leaf unlink, which is the residual this PR has
declared from the start: POSIX has no unlink-by-inode and the stdlib's own
_rmtree_safe_fd addresses names too. Half of it turned out to be closable for free, and
that half is now closed - the pinned scan records each file's inode straight from the
directory block, and the unlink refuses a name that no longer denotes the object the scan
saw. What remains is the two syscalls between that check and the unlink, and a file
substituted BEFORE the scan under a name the manifest already lists.

Closing the rest needs per-FILE inodes carried from the approval snapshot and a
rename-verify-delete per leaf: roughly 50% more syscalls on a path whose whole point is a
batch of tens of thousands of files, plus tens of megabytes of map held for the job's
lifetime. That is a cost/benefit call on a limit the issue explicitly accepted, so it is
raised for a maintainer decision rather than decided here.

Mutation (round 5) Test that caught it
drop the leaf identity check file-swapped-after-the-scan-is-not-unlinked

244 targeted tests pass on this head.

The Hindi string in this PR broke src/i18n/style/hiStyle.test.ts: section 4 of the Hindi
style guide asks for the informal second-person pronoun rather than the formal one, and both
the original branch's line and my corrected one opened with the formal possessive. It uses
the informal form now, and all 11 locale style suites pass (83 tests). Worth naming because I
first mis-classified that shard as an infra flake on the strength of a log I could not fully
retrieve; it was mine all along.

Round 6, after review. One genuinely new finding and it was mine: the manifest's debris name
was deterministic while the coarse path's staging name was already random, and os.rename
replaces its destination silently on POSIX - so a file planted at that name in the trash root
would have had its only copy destroyed without a word. It carries a random suffix now.

Mutation (round 6) Test that caught it
revert the debris name to a deterministic one manifest-debris-name-cannot-overwrite-a-planted-file

245 targeted tests pass on this head.

Round 7. First Principles flagged that the opening walk was a SECOND spelling of the
repository's pinned-filesystem module -- created after two closed PRs (#2446, #2447)
precisely so this mechanism would not be restated per call site. Fair hit, and adopted
rather than argued: the root walk is now that module's pin_parent, the capability probe is
its supports_pinned_tree_walk() plus the three mutating dir_fd calls this path adds, and
the flags come from its _dir_flags() -- called, not captured at import, so the
Windows-simulation tests that delete os.O_NOFOLLOW at runtime still see the truth. The
approval-map machinery (_scan_batch, _open_chain) is genuinely new and stays. 245
targeted tests pass, and that module's own 61 tests still pass alongside them.

One subtraction declined with a reason: dropping the expect=None default on empty_trash.
The None branch is not test-only -- expect.get(batch_id) returns None for any batch the
snapshot dropped, which is the documented unreadable-at-snapshot case -- so removing the
default would not remove the branch, only make the library API harsher.

Every test that needs openat skips explicitly where the platform lacks it, so the Windows shards exercise the coarse path rather than silently asserting nothing.

A tenth finding, after the rebase

The rebase voided the recorded override, GPT re-reviewed the new head, and it found
something the earlier rounds had not: a symlink at the manifest's name was unlinked
before the batch finished.

It is a hole in this PR's own fix rather than a pre-existing one. The manifest is
excluded from the FILE removal pass precisely so it can go last -- it is the only thing
that keeps a batch restorable, and list_trash() omits a batch without a readable one.
But the scan classifies a symlink as a LINK, and the link pass unlinks every link it
recorded, with no manifest exemption. So a symlink at that name went early, and a batch
that then failed to finish -- one unwritable directory, one file held open -- left files
on disk the user could neither see nor restore. That is the exact outcome the
manifest-last rule exists to prevent, reached by the one path that skipped it.

The batch is now refused before anything is removed, reported as unreadable_batch.
Refusing beats deferring the symlink to the end alongside the real manifest: the product
writes that file with atomic_write, so a symlink there was not written by us, and the
entries the approval was computed from were read THROUGH it -- they may not describe this
batch at all. A batch kept costs the user a second attempt; a batch deleted on a
substituted listing costs them the data.

No new user-facing string: unreadable_batch already means "could not be read in full,
so it was left alone", which is exactly what happened, and inventing a sixth reason
would have shipped 13 more locale entries for an adversarial edge case.

Mutation-verified: with the guard disabled the batch proceeds and frees 10 bytes instead
of refusing, which is the data-loss path itself, and
test_a_symlinked_manifest_is_refused_rather_than_unlinked turns red. 254 tests pass
across the two storage files, flake8 / isort / black / mypy clean. The spec records the
rule and names the test.

Tally after this: ten findings, nine fixed, one override -- the POSIX leaf-unlink and
coarse-rmtree residual that issue #5430 accepted up front. That override was recorded
against the pre-rebase head and does not carry to this one.

An eleventh and twelfth finding, same round

GPT raised a second new one on the next head: the directory removals still addressed a
name.
The chain check admits only the directory the approval named, but rmdir
addresses a name and so did that check, so an actor with write access to the parent could
swap the name between the two and have an unapproved directory removed on another one's
approval.

Fixed the way this PR already handles the manifest and the batch directory:
_remove_scanned_dirs() renames each directory to .<name>.removing-<random> in the same
parent, re-checks dev/ino there against the approved map, and removes THAT. A swap that
beats the rename gets the intruder moved within its own parent rather than deleted, is
then refused, and is renamed back -- so a refusal never leaves a directory under a name
the user cannot recognise.

Worth saying why this one is fixed rather than accepted like the leaf unlink. The leaf
residual is per FILE: closing it on the 55k-session batch this serves costs a rename and
a re-stat per file. Directories are a handful per session, so the same technique is
affordable here, and it is already in this file twice.

Two tests, and getting the evidence right took two attempts. My first version hooked
every chain open, which fired during the FILE phase instead -- so it re-tested the
sibling case an existing check already covers, and BOTH mutations passed while I was
claiming they proved something. Gating the swap to the removal pass fixed that.
test_a_directory_swapped_after_its_identity_check_is_not_removed now reds when the
re-check is disabled (the empty SUCCEEDS, which is the unapproved directory being
deleted), and test_a_directory_is_removed_under_a_name_nothing_can_predict reds when the
staging rename is removed. Each mutation reds its own test and only its own.

256 tests pass across the two storage files; flake8, isort, black and mypy clean. The spec
records both halves and names both tests.

Tally: twenty-six findings (twenty-four from review, two from my own audits), twenty-five fixed, one accepted residual -- the POSIX leaf unlink, which
has no fix that is not a per-file cost on a six-figure batch.

The twelfth was the same defect one level up, at the batch's own directory, and I should
have generalised when I fixed the interior ones instead of waiting to be told twice. It is
also the version that did the most damage rather than the least: the final scan proves the
batch empty by DESCRIPTOR, and by the time it is removed the manifest has already been
moved aside -- so a swap in that interval removed an empty replacement and left the real
batch holding data with nothing to list it, while the caller reported SUCCESS. Silent, and
on the success path.

_remove_pinned_batch() now moves the name to .<batch id>.removing-<random>, checks it
against os.fstat(batch_fd), and removes only that. It raises on refusal rather than
reporting, which lets the existing recovery run unchanged: the manifest is renamed back
through the descriptor, so it lands in the REAL batch and that batch stays listed and
restorable.

test_a_batch_swapped_before_its_removal_is_refused_and_keeps_its_manifest pins it, with
the manifest move as the trigger so the swap lands exactly in the interval. Disabling the
identity check makes it fail by reporting success with no skip -- the defect itself, not a
proxy for it.

One existing test needed updating rather than fixing:
test_a_batch_whose_directory_will_not_go_reports_a_reason forced the failure by matching
rmdir against the batch id, which the staging name no longer is. Its assertion is
unchanged; only the way it provokes the failure moved, and it now recognises both names.
Left as it was it would have passed while quietly testing nothing.

257 tests pass across the two storage files; flake8, isort, black and mypy clean.

One more, found by auditing rather than by review

Having been told twice that a removal addressed a name, I stopped waiting and swept every
removal in the module. One more had the same shape, and it was the weakest of them: the
link pass unlinked every recorded link with NO identity check at all -- not even the
two-syscall one the leaf file has.

The reasoning in the code was "removing a link destroys nothing, because the thing it
points at is untouched". True of the link the scan SAW. Not true of whatever holds that
name when the pass runs: a regular file moved onto a recorded link's name is data, and
unlinking it is precisely the loss the file pass's identity check exists to prevent.

_scan_batch() now records each link's inode rather than just its path, and the pass
demands S_ISLNK plus the recorded dev/ino before unlinking. That closes the
scan-to-unlink interval. The two syscalls between the check and the unlink stay open, and
that is the same POSIX residual the leaf file carries -- named, not implied.

Getting the test honest took a correction again. My first version swapped the file inside
os.stat, which put it between my own check and my own unlink -- the irreducible window,
not the one the fix closes -- so it failed against a correct fix. Swapping right after the
scan tests the interval the check actually covers:
test_a_file_swapped_onto_a_scanned_link_is_not_unlinked passes with the check and reds
without it, with the planted file deleted.

258 tests pass across the two storage files; flake8, isort, black and mypy clean.

The Windows path had the same hole

GPT's next one: my manifest refusal lived inside the descriptor branch, so the coarse path
returned before reaching it. On a platform without descriptors, rmtree would remove a
linked manifest and leave any staged file it could not delete -- a locked one -- so the
batch loses its listing and keeps its data. The same loss, reached by the branch that has
no descriptors to reason with.

The check is now ABOVE the platform branch and above the manifest read, so nothing is
deleted, and no listing is even consumed, on the strength of a link. It uses
platform_compat.is_link_or_junction() rather than is_symlink(), because on Windows a
junction reports False for the latter and the coarse path IS the Windows path -- the module
already had that helper for exactly this reason.

The descriptor path still checks the same thing from its pinned scan, and I checked whether
that was now redundant rather than assuming: the path check cannot see a link planted after
it, the scan's view can, and each is pinned by its own test.

Three tests, and one of them was wrong twice before it was right. My first attempt planted
the link inside _manifest_rels, which corrupted the CALLER's unlisted-files read instead:
the refusal came from a guard one level up, the test passed for the wrong reason, and
mutating the check it claimed to cover changed nothing. Re-hooking to
_open_absolute_nofollow -- after the path check and after the caller's read, the only
interval left -- makes it red properly, with incomplete instead of unreadable_batch.

Mutation results are now cleanly separated: removing the hoisted check reds only the coarse
test (148 bytes deleted), removing the scan check reds only the planted-after test. Neither
covers for the other, which is the evidence that both earn their place.

260 tests pass across the two storage files; flake8, isort, black and mypy clean.

The recovery could overwrite data too

The manifest is moved aside so the batch can be removed, and put back if that fails. The
putting-back used rename, and POSIX rename REPLACES its destination silently -- which is
the exact property the debris name three paragraphs up is randomised to be safe against. I
reasoned about that property in one direction and not the other. If anything writes a
manifest.jsonl into the batch while ours is aside, the recovery destroyed the only copy of
a file this code has never read.

The restore is now os.link, which fails with EEXIST instead, and the debris is unlinked
only after the batch has its manifest back, so no window has neither. os.link joins the
_FD_SAFE_DELETE capability set: a platform that cannot do it now takes the coarse path
rather than reaching a recovery it cannot perform safely.

test_manifest_recovery_never_overwrites_a_manifest_that_arrived_since plants the arriving
file in the window, which also makes the batch non-empty so the removal fails on its own and
the recovery runs for real. Reverting the restore to rename reds it, with our manifest's
bytes where the arriving file's should be.

A note on how that mutation went, since it is the same trap as before: my first attempt
patched the wrong line and the test stayed green. A green mutation run is not evidence, it
is a signal that the mutation did not land -- so I now print the mutated line and read it
before believing any result. That check is what caught it.

261 tests pass across the two storage files; flake8, isort, black and mypy clean. While
editing the spec I clobbered a heading with a careless replacement and restored it in the
same pass; the section now has all five containment rules.

The post-condition trusted a name, and so did the step after it

The final scan proved "nothing left but the manifest" by NAME. Everything after it treats
whatever answers to that name as the batch's own manifest: it is renamed aside, and once the
batch is gone the debris is unlinked. So a file substituted at that name after the first
scan satisfied the post-condition and was then destroyed -- an unapproved file, whose only
copy it was, deleted for matching a name.

The survivor's inode must now equal the one the first scan recorded, and a mismatch reports
incomplete and leaves the file alone -- not even moved aside.
test_a_file_substituted_at_the_manifests_name_is_not_destroyed swaps the file between the
directory removal and the post-condition scan; reducing the comparison back to the name reds
it, and it reds by reporting SUCCESS, which is the defect rather than a proxy for it.

This one is worth naming as a pattern rather than an incident. Six rounds in a row have
found the same mistake in a different place: a check that establishes an identity, followed
by an action that addresses a name. I fixed the file pass, then the directories, then the
batch, then the links, then the coarse path, then the recovery, and each time I fixed the
instance rather than the class. The remaining name-addressed action is the leaf unlink, which
is the accepted residual -- POSIX has no unlink-by-inode -- so the class is now closed
everywhere it can be closed, but I would rather record that it took six rounds than imply it
took one.

262 tests pass across the two storage files; flake8, isort, black and mypy clean. The spec
now carries six containment rules, and I re-read the edited region this time.

Having named the pattern, I went looking for the next instance instead of waiting for it,
and found one: the rename that moves the manifest aside. The post-condition now verifies
that file's inode, but the rename addresses its NAME two syscalls later, and nothing
afterwards asked whether what landed was the file that had been verified -- so the unlink
that ends the successful path would have destroyed a substitute.

It cannot be checked before the fact, because POSIX has no rename-by-inode. What can be
checked is the result: the debris's inode is compared against the one the first scan
recorded, and on a mismatch it is LEFT as debris rather than removed, with both names and
both inodes logged at ERROR. The real manifest was already replaced by then, and that loss
is not this code's to undo -- but it does not have to add a second one.

test_a_file_swapped_after_the_post_condition_is_not_deleted_as_debris swaps the file
between the post-condition scan and the rename, and asserts the moved file survives with
its contents. Removing the landed check reds it by reporting success.

That is now every instance of the class this path contains. The only name-addressed action
left without a result check is the leaf unlink -- the residual #5430 accepted up front --
and I have re-walked the module to say that rather than assume it.

263 tests pass across the two storage files; flake8, isort, black and mypy clean. One
self-inflicted detour worth recording: my first version of this used elif after an
except block, which is not valid Python, and mypy caught it as a syntax error before any
test ran.

The approval bound a name too

The class turned out to reach one surface further than the delete path: the SNAPSHOT.
staged_targets() recorded the batch's identity by opening its path, and paired that with
the byte total list_trash() had read from the same path earlier, under no lock. A swap in
between pairs the REPLACEMENT's identity with the original's numbers -- and the delete, which
faithfully checks the identity it was handed, would then destroy session data the user was
never shown, having been asked to approve a different batch's size.

Both halves now come from one pinned descriptor (_approve_batch()): the directory is opened
O_NOFOLLOW, fstat gives the identity, the interior map comes from that descriptor, and the
manifest is re-read THROUGH it rather than by path. _manifest_records() already took an open
handle, so this needed an opener rather than a second parser. _identify_batch() is gone -
_approve_batch() supersedes it, and leaving both would have been two spellings of the same
question.

test_the_approval_binds_identity_and_size_to_one_directory swaps a DIFFERENT staged batch
into the selected one's name after the listing is read, then asserts that whatever is approved
has its identity and its size describing the same directory. Restoring the listing's byte
total reds it: 66 bytes from the vanished batch paired with the impostor's identity.

264 tests pass across the two storage files; flake8, isort, black and mypy clean.

Two process notes I would rather write down than leave in the commit history. I twice made the
same careless spec edit this round -- a replacement whose old and new text differed only by a
newline, which silently joined two words ("batchesan", "removaladdresses"). I caught both by
re-reading, then switched to a script that asserts its anchor and greps for joined words
afterwards. The resulting spec diff is a pure insertion, which is what it should have been the
first time.

The approval kept the directories and threw the files away

The last one in the family, and it had been hiding behind a fix from earlier in this PR.
Round five moved the interior DIRECTORY map from delete time to approval time, because a map
built at delete time records the impostor along with everything else. The FILES never made
that move: _approve_batch scanned them and discarded them, so the per-file identity check
compared each name against the delete's own scan -- self-consistent, and authorising nothing.
A listed file replaced during the handoff had its replacement's inode recorded, matched, and
was unlinked.

BatchIdentity now carries files and links, and the delete demands equality of the whole
map in both directions, exactly as it already did for dirs. A file added, removed or
replaced since the approval is identity_changed rather than something to reconcile, and a
concurrent restore lands there too -- the same answer the directories give, for the same
reason.

I weighed declining this one. The memory objection that justifies the accepted leaf residual
(tens of MB on a 55k-session batch) seems to apply to storing an inode per file -- but the
approval ALREADY stores one per directory, and a batch of that size has directories in the
same order as files. So the cost is a constant factor on something this PR shipped four rounds
ago, not a new category, and declining would have been borrowing an argument that does not
fit.

test_a_listed_file_replaced_after_approval_is_not_unlinked replaces a listed file with one
of the SAME SIZE after the approval, so only the identity distinguishes them. Removing the
comparison reds it by unlinking the replacement and reporting 598 bytes freed.

265 tests pass across the two storage files; flake8, isort, black and mypy clean. The spec
records it, and the spec diff is a pure insertion this time -- twelve added lines, nothing
removed.

The listing itself, and a silent success

Two findings this round.

The manifest's CONTENTS were not bound to the approval. Its inode was, as of two rounds
ago -- but rewritten in place the manifest keeps that inode, every file identity still
matches because no file changed, and what the rewrite alters is which files the delete
believes it may unlink. A file already sitting in the batch, unlisted, is refused at delete
time; add it to the listing after the approval and it is deleted as though the user had
approved it. BatchIdentity now carries a digest of the approved rels, and a mismatch is
identity_changed. A digest rather than the rels keeps the approval constant-size on a batch
with six figures of entries.

Writing the test corrected me on my own code: I asserted that the approval refuses a batch
holding an unlisted file, and it does not -- that refusal lives at delete time. Which makes
the path SHORTER than I described when I started, because nothing upstream stands in the way.
The assertion is gone and the test says where the refusal actually is.

A selected batch that cannot be approved was silently omitted, so the worker reported
success for a batch still on screen -- the same bug the missing-id refusal ten lines above
exists to prevent. Partly mine: last round I widened _approve_batch to also return None
when the manifest cannot be summarised, so I made the quiet path quieter.

It now raises for a NAMED selection, which is exactly the asymmetry that missing-id rule
already draws. I did NOT make it raise on the unnamed sweep, and that is a deliberate
departure from the suggested fix: one batch damaged by a crash mid-append would make the
whole trash un-emptyable, and the delete path skips rather than aborts for that reason. The
dashboard already answers a SessionStorageError from this call as a 400 rather than a job,
so the refusal surfaces as a refusal rather than a 500 -- checked, not assumed.

267 tests pass across the two storage files; flake8, isort, black and mypy clean. Each
mutation reds only its own test: dropping the digest deletes the newly-listed file, dropping
the raise restores the quiet success.

My own fix had the bug it was fixing

The digest I added last round was computed by PATH and AFTER the interior scan. Both halves
of that are wrong, and they reintroduce a narrower version of the hole the digest closes: a
manifest rewritten between the scan and the digest is recorded as the NEW listing against
the OLD inode maps, which authorises precisely the file the digest exists to refuse. Read by
path it could also describe a different directory's manifest entirely.

It is now captured first, through the pinned descriptor. Ordering it first is what makes it
fail closed: a rewrite after that point leaves the digest describing the old listing, so the
delete refuses.

_read_manifest() and _manifest_rels() grew the same optional dir_fd that
_summarize_manifest() already had, so the pinned read has ONE spelling rather than a
second parser beside the first -- the divergence that an earlier round of this review
rightly objected to.

test_a_manifest_rewritten_during_the_approval_does_not_authorize_it rewrites the manifest
in place inside the approval, keeping its inode. Moving the capture back after the scan reds
it, and reds it by deleting the smuggled file.

268 tests pass across the two storage files; flake8, isort, black and mypy clean.

Worth naming plainly: this is the second time a fix of mine in this PR shipped the same class
of mistake it was closing -- the first was reasoning about rename replacing its destination
in one direction and not the other. Both times the fix was right about the mechanism and
careless about ordering, which is a pattern in how I write these rather than an accident.

The approval followed a link, and the sweep still lied

Path.resolve() follows the final component. So a batch directory replaced by a symlink
resolved to its TARGET, and the pinned walk then pinned that target: the approval recorded
another directory's identity under this batch's id, and the delete, faithfully checking the
identity it was handed, would destroy session data from outside the trash. The approval now
resolves only the parent and re-joins the batch's own name, which keeps O_NOFOLLOW on the
component that matters.

My first test for this was worthless and the mutation is what told me. It pointed the name at
a directory with no manifest, which the approval refuses for THAT reason -- so it passed with
the bug present. Pointing the name at a SECOND real batch, manifest and all, leaves the
resolution as the only thing that decides: with the fix the approval refuses, and with
resolve() restored it hands back the other batch's identity, files and digest under the
first batch's id.

The unnamed sweep, which I declined to change last round. I argued that raising would let
one crash-damaged batch make the whole trash un-emptyable, and that stands. What I missed is
that those are not the only two options. The batch now stays in the id list WITHOUT an
approval, and empty_trash refuses an id that a supplied approval map does not name -- so it
comes back as a skip the user can read rather than vanishing from the job beneath a success
message. No signature change, no product decision deferred.

That refusal turned out to matter more than the reported bug: with the membership check
disabled the unverified batch is DELETED, 552 bytes freed. So the omission was not merely
mis-reported before -- an id reaching the worker without an approval was being deleted
unchecked, which is the outcome the whole map exists to prevent.

270 tests pass across the two storage files; flake8, isort, black and mypy clean.

One CI red this round was neither mine nor a real failure: Frontend Tests shard 3 reported
421 files and 6698 tests passed, 0 failed, then died with [vitest-pool]: Worker exited unexpectedly after the suite finished. Frontend Coverage Merge is downstream of that shard.
Nothing in this round touches the frontend, and the push has since started a fresh run.

A refusal no longer writes to the name it just refused

Third instance of the same blind spot of mine: rename replaces its destination, and I
reasoned about that for the manifest recovery two rounds ago and then wrote the directory
rollback with the same courtesy. Renaming a refused object back onto the listed name writes
to a name the refusal has just proved is NOT ours. Now only a matched identity is renamed
back -- when the object is ours and only the removal failed. A mismatch, or a re-check that
could not be read, leaves it under the unguessable staging name with both names logged.

Two of my own earlier tests asserted the opposite contract -- that the intruder is renamed
back and no staging debris remains -- and I have rewritten both, because that contract was
wrong rather than because they were inconvenient. The reasoning I gave for it then ("a
refusal must not leave the directory under an unrecognisable name") loses to this: not
writing to a name that is not ours beats leaving a tidy tree.

One correction to the finding's severity. It describes the rollback destroying a victim
placed at the listed name. For a DIRECTORY that is not reachable: POSIX rename fails against
a file (ENOTDIR) and against a non-empty directory (ENOTEMPTY), so the most it can destroy
is an empty directory. I wrote a test to demonstrate the data loss, watched it pass with the
bug deliberately reinstated, and deleted it rather than ship evidence that proves nothing --
the mutation passing IS the answer here, not a testing mistake. The fix stands on the narrower
ground, and the wider ground is real elsewhere: the same courtesy applied to the manifest,
which is a file, and there it did destroy the only copy, which is why that path uses
os.link.

The behaviour is pinned by the two rewritten tests: reinstating the rollback on mismatch reds
test_a_directory_swapped_after_its_identity_check_is_not_removed, because the intruder ends
up back under the listed name with no debris.

270 tests pass across the two storage files; flake8, isort, black and mypy clean.

Where the rest of the class lives, and who owns the extraction

Two advisories on this head, and both are about scope rather than correctness. Recording the
answers here because they are the shape of the follow-up, not loose ends.

The two remaining shutil.rmtree-by-path siblings are _discard_restored_batch and
move_to_trash's empty-batch cleanup. The ancestor-swap exposure survives on both until that
lands, and the follow-up is FILED rather than promised in prose: issue #7113.

Design review's stronger point is that the rename-verify-remove pattern is now spelled three
times inline -- interior directories, the batch directory, the coarse path -- and that
pinned_fs exists precisely because per-call-site respellings of this mechanism failed twice
before (#2446, #2447). That is the same objection an earlier round of this review made about
the link cell, one level up, and it is right: fixing the siblings by copying this machinery a
fourth time would repeat the history the module already has. The extraction belongs with that
work, where two more call sites will show which primitives are genuinely batch-agnostic, and
it is recorded on #7113 rather than left in this thread.

The selected NAME needed checking, not just the pinned directory

The approval binds identity, files, size and listing to one pinned directory -- and all of
that can still describe the wrong batch. A directory renamed into the selected name after
list_trash() brings its own manifest, so the approval is perfectly self-consistent and
authorises deleting a batch the user never selected. The name is the only link back to the
selection, and nothing was checking it.

This PR already has the rule: "the directory is the batch's identity, not the manifest
header", and list_trash() withholds a batch whose header claims a different id. The rule was
simply enforced at LISTING time only, while the approval reads the manifest again afterwards --
and the swap lands between the two reads. _header_names_this_batch() applies the same rule on
the second read.

It is worth being precise about why this does not contradict the rule it comes from. The header
is only ever COMPARED, never resolved in its favour: a disagreement withholds the batch, which
is exactly what the listing does. Trusting the header to decide WHAT to delete would be the
thing that rule forbids.

A test of mine got stronger rather than being rewritten to fit.
test_the_approval_binds_identity_and_size_to_one_directory used to assert only that whatever
came back had its identity and size describing ONE directory -- the impostor's -- because
binding the pair was all the code could then promise. It now expects the swap to be refused
outright. Disabling the comparison reds it with DID NOT RAISE.

270 tests pass across the two storage files; flake8, isort, black and mypy clean.

Rebase, 2026-08-31

Rebased onto main at maintainer request (Raymond), which supersedes the push-forbidden
state the recorded override put this branch in. One conflict, in
test/test_session_storage.py: main and this branch had each added imports to the same
block. Resolved as the union of both sides in isort order -- shutil and
PurePosixPath are this branch's, time and Callable are main's, and all four are
still used by the tests that introduced them.

Still exactly one commit. Targeted gates on the rebased head: 253 pass across
test_session_storage.py and test_session_storage_api.py (up from 245 -- main added
tests to these files while this branch was open), plus test_subagent_scale.py at 52
pass because it is the only other file naming any of the changed APIs. flake8, isort,
black and mypy clean on the four touched Python files; tsc clean and 133 frontend
tests pass across the storage screen and the locale style suites. The three eslint
warnings on SessionStorageScreen.tsx are byte-identical on main's copy of the file at
the same line numbers, so they are pre-existing rather than introduced here.

Two things a reviewer should know. First, the human override recorded against
fcdd6f247 does not carry to this head -- a rebase voids it by design -- so the GPT
lane will re-raise the residual that override covered, and it needs re-applying on the
new head before this can go green. Second, the pre-push secret gate blocked the push
with 47 hits and I used its audited override: every hit was in main's OWN commit
messages, which the force-push swept into the scanned range because that path computes
remote..local without the --not --remotes guard its new-branch path uses. Running
the same scanner over only the commits authored here returns clean, and main's history
is already on the remote, so nothing new was exposed. Naming it rather than leaving it
to the audit log.

Any other suggestions on the work

  • The work was written and reviewed as part of feat(system): report progress while the session Trash empties #5195 and split out of it deliberately; this is that branch's content ported onto current main, since main has moved past the base it was cut from. Two translations of the new skip-reason string were mis-spelled on the original branch and are corrected here: the Bengali "changed" verb was mistyped, and the Hindi line both mistyped "after you selected" and used the word for an ox in place of the loanword for a batch.
  • _clear_by_descriptor recurses, matching the stdlib's _rmtree_safe_fd. The depth is the depth of the tree on disk and a batch this module writes is two levels deep, so this is parity rather than a new risk - but an iterative sweep would remove the question entirely if a future batch layout gets deeper.
  • A cheaper complementary step recorded on the issue: os.rename each approved batch to a delete-staging name atomically under the lock at request time. It is strictly weaker than binding the delete to an fd (the staging name is still a name), so it is not a substitute, but it would shrink the window for any caller that cannot take the descriptor path.

Closes #5430

@chenmingwei23
chenmingwei23 requested a review from a team August 30, 2026 15:38
@chenmingwei23
chenmingwei23 requested a review from a team as a code owner August 30, 2026 15:38
@chenmingwei23
chenmingwei23 requested a review from pepmach August 30, 2026 15:38
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 30, 2026
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of 4d3e224f35a53bda0003d339f5e86234a8a9e808 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: PASS

A single new kept-reason string that maps the backend identity_changed code to plain, front-loaded prose, consistent with its five sibling strings and localized across all 12 locales.

The message "One batch changed on disk after you selected it, so it was kept." reads clean cold, states what happened without leaking the internal code, renders in text-warn (correct for a partial failure, not muted), and the kept batch stays in the Trash with Restore / Delete forever adjacent — the next step is discoverable at the locus. Screenshot number mismatch (Freed 268.4MB vs a 268.4MB batch still listed) is harness-supplied demo inventory per the PR, not shipped behavior.

[UX-REVIEWED] 4d3e224

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 4d3e224f35a53bda0003d339f5e86234a8a9e808 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound descriptor-pinned design; the risk is what rides on the follow-up — two sibling paths keep the exact data-loss exposure this PR exists to close.

Watch

  • _discard_restored_batch and move_to_trash's empty-batch cleanup still rmtree a re-resolved path ("the ancestor-swap exposure remains on those two paths"), in the same module, crossing the same untrusted-agent boundary. Merging ships a half-closed class; Session trash: two sibling paths still remove a batch by re-resolved path #7113 carries real security payload, not cleanup, so its priority should be set accordingly.
  • The rename-verify-remove pattern is now spelled three times inline (interior dirs, batch dir, coarse path) in session_storage.py — the per-call-site respelling pinned_fs was created after fix: include artifact library and uploads in snapshot/restore #2446/fix: include chat sessions in snapshots so restores are internally consistent #2447 to end. The extraction is deferred to the same follow-up; a fourth inline copy when the siblings are fixed is the failure mode the module's own docstring names.
  • The Windows coarse path deliberately keeps a guessable-window rmtree instead of failing closed, with the author explicitly offering the one-line strict flip as "a maintainer decision rather than decided here." A human should actually make that call at merge, not inherit it by default.

[DESIGN-REVIEWED] 4d3e224

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 4d3e224f35a53bda0003d339f5e86234a8a9e808 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 4d3e224

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 4d3e224f35a53bda0003d339f5e86234a8a9e808: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 30, 2026
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 4d3e224f35a53bda0003d339f5e86234a8a9e808 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Confirmed: _discard_restored_batch (session_storage.py:1380) and the move_to_trash empty-batch cleanup (session_storage.py:1936) both still call shutil.rmtree(<path>, ignore_errors=True), so the two deferred siblings the description names are real (count: 2).

First-Principles-Verdict: CONCERNS

A cause-level fix to an irreversible delete where every guard closes a named data-loss window — but it point-fixes the empty path and leaves two confirmed same-cause siblings still deleting by re-resolved path.

What this change ships

Intent: make emptying the session Trash destroy exactly the batch the user approved and never escape the trash, instead of rmtree-ing whatever answers to a name at delete time. This is a FIX (irreversible-data-loss / trash-escape).

  1. POSIX delete walks by descriptor with O_NOFOLLOW instead of rmtree(path) — justified
  2. Files removed by (dir fd, name) from the manifest, never os.walk discovery — justified
  3. staged_targets carries BatchIdentity (dev/ino + interior dir/file/link maps), re-checked at delete — justified
  4. Named selection with an unreadable snapshot now fails CLOSED (was: dispatched anyway) — justified, declared
  5. Refused empty now SEL-audited at the handler — justified, declared
  6. rels_digest pins the approved listing against an in-place manifest rewrite — justified
  7. Manifest removed last via rename-aside with link/exclusive-copy recovery — justified
  8. Coarse/Windows path renames-then-verifies identity before rmtree — justified
  9. _dir_flags → public pinned_fs.dir_flags() (1 consumer, anti-divergence) — justified
  10. New user string kept_identity_changed across 12 locales + screen mapping — justified, declared
    (Change has more observable differences — e.g. per-file progress; these are the 10 most noticeable. All ride on the fix with named causes.)

Watch

Point patch with 2 counted, same-cause siblings (_discard_restored_batch at session_storage.py:1380, move_to_trash cleanup at :1936) still removing a trash directory by re-resolved path, so the ancestor-swap exposure remains there. The author declares this and defers it with a defensible rationale (the empty path runs minutes post-click; those two are the smaller window). Accepted-and-deferred, not a demand — surfaced so the residual is visible, not to hold the merge.

[FIRST-PRINCIPLES-REVIEWED] 4d3e224

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 4d3e224f35a53bda0003d339f5e86234a8a9e808 — this comment is updated in place on each push.

Review details

Based on my independent examination of the production code changes (session_storage.py, dashboard/handlers/session_storage.py, pinned_fs.py), I traced the concrete failure paths: the descriptor-pinned scan/open chains, the identity re-checks against the approval map, the fail-closed snapshot audit in the handler, and the dir_flags rename (no stale _dir_flags code references remain — snapshot.py's _dir_flags_nofollow is a distinct function). Every name-addressed step is bound to a re-checked inode or fails toward keeping the batch, and the handler's failure branch never reaches the identities use before it is bound. I could not re-derive a concrete input, call path, and observable wrong outcome for any defect in the changed lines.

No findings.

[OPUS-REVIEWED] 4d3e224

Verdict parsed from the review's SHA-scoped output markers for commit 4d3e224f35a53bda0003d339f5e86234a8a9e808.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 4d3e224f35a53bda0003d339f5e86234a8a9e808: <one-sentence reason>

@chenmingwei23
chenmingwei23 force-pushed the fix/trash-delete-containment-5430 branch from e03450e to 11d3b58 Compare August 30, 2026 16:05
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 30, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

All three GPT findings were real. Fixed in 11d3b5884, none overridden.

1. Mutable descendant names can redirect deletion -- correct, and my first attempt at it was wrong in an instructive way. O_NOFOLLOW refuses a LINK, but a real directory RENAMED onto a staged directory's name is not a link and satisfies O_DIRECTORY, and pinning the batch does not cover its interior because the batch's own inode is unchanged by a rename that happens inside it.

I first tried verifying each component against a scandir of its parent read at open time. That does not work, and the new test proved it: re-reading the parent after the swap reports the impostor, so the comparison agrees with the attacker. The identity has to come from EARLIER than the swap.

So the delete now takes one pinned traversal of the batch before it removes anything (_scan_batch), recording every interior directory's inode keyed by its batch-relative components, and _open_chain admits a component only as the inode that traversal recorded, on the batch's own device. The same traversal also re-establishes the "nothing here the manifest does not name" property from the descriptor rather than from os.walk on a path, so the guard the delete acts on is the one it can bind to.

Mutation-verified: with the inode comparison neutered, test_a_directory_renamed_into_a_staged_name_is_refused stops raising and test_a_directory_swapped_after_the_scan_keeps_its_files fails with FileNotFoundError on the live file -- i.e. the exploit you described, executed.

2. The manifest can disappear before final removal -- correct, and the consequence is exactly as you state: rmdir fails on a directory that acquired a file after the sweep read clear, and the batch, now without a manifest, leaves list_trash() with that file inside it, unreachable and unrestorable.

Taken your suggested shape. The manifest is RENAMED to the trash root under .<batch>.manifest.jsonl.removing, the batch is removed, and the debris unlinked; if the rmdir fails the manifest is renamed straight back, so the batch stays listed and restorable exactly as it was. list_trash() enumerates directories only, so the debris name is never mistaken for a batch, and a crash between the two renames leaves one small file rather than an unreadable batch. Both renames failing is logged at error level with the debris name, which is what a human needs to put it back by hand.

New test test_a_late_file_leaves_the_batch_listed_and_restorable creates a file as the sweep returns and asserts the manifest is back, the batch is still listed, and no debris is left in the root. Reverting to unlink turns it red on "manifest must be back".

3. Identity verification is skipped on the coarse path -- correct, and dropping the check on the platform with the weakest removal is the wrong way round. The coarse path now compares the expected identity too, by lstat of the path before rmtree: a mismatch and a stat that fails both refuse.

Stated plainly in the code rather than implied, because it is weaker than the descriptor check by construction -- the stat and the rmtree are two lookups, so a swap between them still wins -- and it is vacuous on a filesystem that reports no usable inode, since the snapshot then recorded the same unusable value. test_the_coarse_path_also_refuses_a_swapped_batch pins it on every platform by handing the delete a stale identity, and is red without the comparison.

Gates re-run on the new head: 238 passed in test/test_session_storage.py + test/test_session_storage_api.py, flake8 / isort / mypy clean, both touched Python files black-clean under the repo config (neither is in the baseline).

Screenshot Evidence is still red and I am producing the capture; that gate wants a rendered image of the new refusal row, which is the one thing a diff cannot show.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 30, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/trash-delete-containment-5430 branch from 11d3b58 to 137a64d Compare August 30, 2026 16:21
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 30, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/trash-delete-containment-5430 branch from 137a64d to 30d8724 Compare August 30, 2026 16:49
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 30, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Both round-2 findings were real. Fixed in 30d872492, neither overridden.

Interior replacements are trusted when the deletion-time scan records them -- yes, and this is the same mistake one level up from round 1. I moved the identity earlier when the finding said to, but not far enough: _scan_batch still ran at DELETE time, so a directory swapped in during the async handoff was recorded as legitimate by the very traversal meant to catch it. A map built after the swap agrees with the swap.

The map now comes from staged_targets() while the mutation lock is held, carried in a BatchIdentity alongside the batch's own (st_dev, st_ino), and the delete demands equality in both directions -- a directory added, removed or replaced since approval is identity_changed. The delete-time scan still runs, but only to answer "what is here now"; what authorises the delete is the approval.

Two consequences worth stating rather than leaving to be discovered:

  • A concurrent partial restore that removes a staged directory now makes the empty refuse instead of proceeding. That is the right direction: the approval no longer describes the batch, and the user can re-select.
  • staged_targets() now walks each approved batch's directory tree under the lock. It already parses every batch's manifest via list_trash() on the same call, so this is the same order of work, and it records directories only.

The new test needs no monkeypatching at all -- the swap simply happens between staged_targets() and empty_trash(), which is exactly what the window is. Verifying against the delete-time scan instead of the approval turns it red ([] == ['identity_changed']), and the live file is unlinked.

Worth recording: the crude version of this attack was already refused, just not by the identity check. A directory holding anything the manifest does not name is caught by the unlisted-file guard, so my first attempt at the test failed with unlisted_files -- I had to craft an impostor containing EXACTLY the manifest's names to reach the identity check at all. That is the case the check exists for.

Coarse deletion re-resolves the batch after checking its identity -- correct, and my round-1 fix was decorative for precisely that reason: os.stat(batch) then rmtree(batch) is two lookups of the same name.

The coarse path now renames the batch to .<batch-id>.removing-<8 random hex> -- atomic within the trash root -- verifies the identity of the RENAMED directory, and removes it under that name. After the rename the approved name no longer exists, so nothing can be substituted at it; a swap that happened BEFORE the rename is caught by the check and the impostor is renamed back rather than deleted; and the name finally removed existed for microseconds and carries random characters. A tree that will not go is renamed back too, so it stays listed and restorable instead of being stranded under a name list_trash() does not offer.

I did not take the suggested fail-closed, and want to be explicit about why. The API path always supplies an identity, so "refuse when descriptor-safe deletion is unavailable" means emptying the Trash never works on Windows -- the staged bytes stay on disk forever with no way to reclaim them, on the platform whose users cannot switch. That is a worse outcome than a residual window an attacker has to guess an 8-hex-character name inside. rmtree does still resolve the staging path, and the code says so rather than implying the window is closed. If you would rather have the strict trade, it is one line and I will make it -- but it should be a deliberate product decision, not a side effect of a security fix.

The ordering is now pinned by a test that distinguishes it: the swap fires from inside the identity stat, so a stat-then-remove ordering destroys the impostor and a rename-then-verify ordering leaves it alone. Reverting to check-first turns it red with FileNotFoundError on the live file.

Gates on the new head: 241 passed across test/test_session_storage.py and test/test_session_storage_api.py, flake8 / isort / mypy clean, all three touched Python files black-clean under the repo config. The spec module doc moved with the code, and the screenshot URL in the body is re-pinned to 30d872492.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 30, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/trash-delete-containment-5430 branch from 30d8724 to 16aeaa4 Compare August 30, 2026 17:12
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 30, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 8 disposition, on f42ee91d1. Real, new, and fixed the way you suggested.

The finding. The chain check admits only the directory the approval named, but rmdir addresses a name and so did that check -- so an actor with write access to the parent could swap the name in between and have an unapproved directory removed on another one's approval. _remove_scanned_dirs() now renames each directory to .<name>.removing-<random> in the same parent, re-checks dev/ino there against the approved map, and removes THAT. A swap that beats the rename gets the intruder moved within its own parent rather than deleted, is then refused, and is renamed back by _restore_staged_dir() -- so a refusal never leaves a directory under a name the user cannot recognise.

Why this one is fixed rather than accepted like the leaf unlink. That residual is per FILE: closing it costs a rename and a re-stat for every file in a batch that reaches 55k sessions. Directories are a handful per session, so the same technique is affordable here -- and it is already in this file twice, for the manifest and for the batch directory. The cost argument that justifies the leaf residual simply does not transfer, so declining this one would have been borrowing an excuse rather than making one.

On the evidence, including a correction. My first attempt at the test hooked every chain open, which fired during the FILE phase rather than the removal pass -- so it re-tested the sibling scenario an existing check already covers, and BOTH mutations passed while I was on the verge of reporting them as proof. Gating the swap to the removal pass fixed it. Now: disabling the identity re-check makes test_a_directory_swapped_after_its_identity_check_is_not_removed red, and the failure mode is the telling part -- the empty SUCCEEDS with no skip reported, which IS the unapproved directory being deleted. Removing the staging rename makes test_a_directory_is_removed_under_a_name_nothing_can_predict red instead. Each mutation reds its own test and only its own, which is what tells me the two halves are independently load-bearing rather than one guard doing all the work.

Gates on this head: 256 pass across test_session_storage.py and test_session_storage_api.py, flake8 / isort / black / mypy clean. The spec records both halves and names both tests.

Still outstanding, and not mine to decide. The one accepted residual is the POSIX leaf unlink and the coarse rmtree window, which issue #5430 accepted up front -- there is no unlink-by-inode, the stdlib's own _rmtree_safe_fd addresses names too, and closing it is a per-file cost on a six-figure batch. The override covering it was recorded against the pre-rebase head and I have not re-applied it, because the sign-off was for that finding on that head. Tally now: eleven findings, ten fixed, one awaiting a fresh maintainer decision.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 9 disposition, on 106e1bf2f. Real, fixed, and I should have caught it myself last round.

This is the same defect one level up. Last round I bound the interior directory removals to a re-pinned identity; the batch's own directory still went by name. I fixed the instances GPT named instead of generalising the class, which is why this took two rounds rather than one.

It is also the version that did the most damage, not the least. The final scan proves the batch empty by DESCRIPTOR, and by the time the batch is removed the manifest has already been moved aside -- so a swap in that interval removed an empty replacement and left the real batch holding data with nothing to list it, while empty_trash reported SUCCESS and counted the manifest's bytes as freed. Silent, and on the success path, which is worse than a refusal.

_remove_pinned_batch() now moves the name to .<batch id>.removing-<random>, checks it against os.fstat(batch_fd) -- the descriptor the whole operation was pinned to -- and removes only that name. It raises on refusal rather than reporting, which is what lets the existing recovery run unchanged: the manifest is renamed back THROUGH the descriptor, so it lands in the real batch wherever the swap left it, and that batch stays listed and restorable.

Evidence. test_a_batch_swapped_before_its_removal_is_refused_and_keeps_its_manifest uses the manifest move as its trigger, which puts the swap exactly in the interval under test. Disabling the identity check makes it fail by reporting success with no skip -- that is the defect itself rather than a proxy for it -- and the test also pins that the replacement is untouched and the real batch got its manifest back.

One existing test needed updating, and I want to be explicit that I changed a test rather than only code. test_a_batch_whose_directory_will_not_go_reports_a_reason provoked its failure by matching rmdir against the batch id, which the staging name no longer is; left alone it passed while simulating nothing. Its assertions are unchanged -- batch still listed, bytes still real, incomplete still reported -- only the name-matching that forces the failure now recognises both spellings.

257 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. The spec records this alongside the interior case and names the test.

Tally: twelve findings, eleven fixed, one accepted residual -- the POSIX leaf unlink, whose override was recorded against a pre-rebase head and which I have not re-applied, since that sign-off was for that finding on that head.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Not a review response -- something I found by auditing, on 21a40d43e.

Having been told twice in a row that a removal addressed a name, I stopped fixing named instances and swept every unlink / rmdir / rmtree in the module. One more had the same shape, and it was the weakest of the set: the link pass unlinked every recorded link with NO identity check at all, not even the two-syscall one the leaf file carries.

The comment there argued "removing a link destroys nothing, because the thing it points at is untouched". That is true of the link the scan SAW. It is not true of whatever holds that name when the pass runs: a regular file moved onto a recorded link's name is data, and unlinking it is exactly the loss the file pass's identity check exists to prevent. So the reasoning was sound about links and silent about substitution, which is how it survived nine review rounds.

_scan_batch() now records each link's inode instead of just its path, and the pass demands S_ISLNK plus the recorded dev/ino before unlinking. That closes the scan-to-unlink interval. The two syscalls between the check and the unlink remain open, and I am naming that rather than implying otherwise -- it is the same POSIX residual the leaf file has.

The test needed a correction of the same kind I made last round, which is worth recording because I keep making it. My first version swapped the file inside os.stat, which placed it between my own check and my own unlink -- the irreducible window, not the one the fix closes -- so it failed against a correct fix and would have sent me looking for a bug that was not there. Swapping immediately after the scan tests the interval the check actually covers. test_a_file_swapped_onto_a_scanned_link_is_not_unlinked passes with the check and reds without it, with the planted file gone.

Audit result for the rest, so the sweep is on the record rather than just its one finding:

  • the leaf file unlink keeps its stat-identity check and remains the accepted residual;
  • the interior directory and batch removals are re-pinned as of the last two rounds;
  • the manifest debris unlink addresses a random name this call just created, so there is nothing to substitute;
  • the shutil.rmtree sites are the non-descriptor platform fallback, already a documented Known Limitation, with follow-up issue Session trash: two sibling paths still remove a batch by re-resolved path #7113 open for the two siblings outside this path.

258 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. The spec records the rule and names the test.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 10 disposition, on 573d1b46b. Correct, and it is a placement mistake I made rather than a case I had considered and dismissed.

The finding. My manifest refusal sat inside the descriptor branch, so the coarse path returned before reaching it. On a platform without descriptors rmtree removes a linked manifest and leaves any staged file it could not delete -- a locked one on Windows -- so the batch loses its listing and keeps its data. Same loss as the case I fixed two rounds ago, reached by the branch that has no descriptors to reason with.

The check now sits ABOVE the platform branch and above the manifest read, so nothing is deleted and no listing is even consumed on the strength of a link. It uses platform_compat.is_link_or_junction() rather than is_symlink(), because on Windows a junction reports False for the latter and the coarse path IS the Windows path -- the module already had that helper, with a comment saying exactly this, which I should have followed the first time.

I checked whether the scan-based check is now redundant instead of assuming either way. It is not: the hoisted check is computed from a path and cannot see a link planted after it, while the pinned scan's view can. Each is pinned by its own test, and the mutations separate cleanly -- removing the hoisted check reds only the coarse test (148 bytes deleted), removing the scan check reds only the planted-after test. Neither covers for the other.

One test was wrong twice before it was right, and the failure mode is worth naming. My first version planted the link inside _manifest_rels, which corrupted the CALLER's unlisted-files read rather than the interval I was aiming at: the refusal came from a guard one level up, the test passed for the wrong reason, and mutating the check it claimed to cover changed nothing at all. That is the second time this session a green test has told me nothing, and both times the tell was the same -- the mutation did not red. Re-hooking to _open_absolute_nofollow, which is after the path check and after the caller's read, leaves only the interval the scan can see, and it now reds with incomplete instead of unreadable_batch.

260 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. The spec records both checks, says why they are not duplication, and names all three tests.

Tally: fourteen findings, thirteen fixed, one accepted residual -- the POSIX leaf unlink, whose override was recorded against a pre-rebase head and which I have not re-applied.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 11 disposition, on 007b9c514. Real, fixed as you suggested, and the interesting part is that I already knew the fact it turns on.

The finding. The manifest is moved aside so the batch can be removed and put back if that fails. The putting-back used rename, and POSIX rename replaces its destination silently -- which is the exact property the debris name three lines above is randomised to be safe against. My own comment there spells it out. I reasoned about it in one direction and not the other, so if anything writes a manifest.jsonl into the batch while ours is aside, the recovery destroyed the only copy of a file this code has never read.

The restore is now os.link, which fails with EEXIST, and the debris is unlinked only after the batch has its manifest back -- so no window has neither, which was the property the original rename got right and I did not want to lose. os.link also joins the _FD_SAFE_DELETE capability set, so a platform that cannot do it takes the coarse path rather than reaching a recovery it cannot perform safely; that seemed better than discovering the gap at recovery time, which is the worst moment to find out.

Evidence. test_manifest_recovery_never_overwrites_a_manifest_that_arrived_since plants the arriving file inside the window; the same file makes the batch non-empty, so the removal fails on its own and the recovery runs for real rather than being forced. Reverting the restore to rename reds it, with our manifest's bytes sitting where the arriving file's should be. The test also pins that the debris survives under the name the log reports, so a human can still put things back by hand.

On the mutation, because it nearly fooled me again. My first attempt patched the wrong line -- the sed matched a try: -- and the test stayed green. Under the habit I had two rounds ago I would have read that as "verified". A green mutation run is not evidence; it means the mutation did not land. I now print the mutated line and read it before believing any result, and that is what caught this one. Third time this session that a green result was the tell rather than the reassurance.

261 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. One more thing worth owning: while editing the spec I clobbered a heading with a careless replacement, noticed on re-read, and restored it in the same pass -- the section now carries all five containment rules.

Tally: fifteen findings, fourteen fixed, one accepted residual -- the POSIX leaf unlink. Its override was recorded against the pre-rebase head and I have not re-applied it, since that sign-off was for that finding on that head.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 12 disposition, on 9ae5a165e. Real, fixed exactly as suggested, and I want to name the pattern rather than just the instance.

The finding. The post-condition proved "nothing left but the manifest" by NAME. Everything after it treats whatever answers to that name as the batch's own manifest -- renamed aside, then unlinked as debris once the batch is gone -- so a file substituted at that name after the first scan satisfied the check and was then destroyed. An unapproved file, whose only copy it was, deleted for matching a name.

The survivor's inode must now equal the one the first scan recorded in present. A mismatch reports incomplete and leaves the file entirely alone -- not even moved aside, which the test pins separately, because "we only moved it" would still be us touching a file we have no claim to.

Evidence. test_a_file_substituted_at_the_manifests_name_is_not_destroyed swaps the file between the directory removal and the post-condition scan. Reducing the comparison back to the name reds it, and it reds by reporting SUCCESS with no skip -- the defect itself. I verified the mutation had actually landed by printing the mutated line first, which is the habit the last four rounds taught me.

The pattern, which matters more than this finding. Six consecutive rounds have found the same mistake in a different place: a check that establishes an identity, followed by an action that addresses a name. The file pass, the interior directories, the batch directory, the link pass, the coarse path, the recovery, and now the post-condition. Each time I fixed the instance you named and each time the next instance was one function away. The class is now closed everywhere it can be: the only name-addressed action left is the leaf unlink, which is the residual #5430 accepted up front because POSIX has no unlink-by-inode.

I would rather say plainly that this took six rounds than present it as a single clean fix. If a seventh instance exists I have not found it -- my own sweep of every unlink/rmdir/rmtree two rounds ago found the link pass, and this one came from you, which suggests my sweep was not the right instrument for checks-followed-by-actions as opposed to bare removals.

262 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. The spec now carries six containment rules and I re-read the edited region before committing, after clobbering a heading last round.

Tally: sixteen findings, fifteen fixed, one accepted residual. That residual's override was recorded against the pre-rebase head and has not been re-applied, which is a maintainer decision rather than mine.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Follow-up to the round-12 disposition, on 31ccde028 -- found by looking for the next instance rather than waiting for it.

I said the class was "closed everywhere it can be closed". That was one instance too optimistic, and re-reading my own sentence is what caught it: the post-condition now verifies the manifest's inode, but the rename that moves it aside addresses its NAME two syscalls later, and nothing afterwards asked whether what landed was the file that had been verified. The unlink that ends the successful path would then destroy a substitute.

It cannot be checked before the fact -- POSIX has no rename-by-inode, which is the same wall the leaf unlink hits. What CAN be checked is the result, so the debris's inode is compared against the one the first scan recorded, and on a mismatch the file is LEFT as debris rather than removed, with both names and both inodes logged at ERROR. The real manifest was already replaced by then and that loss is not this code's to undo; it just does not have to add a second one.

test_a_file_swapped_after_the_post_condition_is_not_deleted_as_debris swaps the file between the post-condition scan and the rename -- keyed on the second _scan_batch call, since the post-condition IS the second scan -- and asserts the moved file survives with its contents intact. Removing the landed check reds it by reporting success. Mutation confirmed applied before I read the result.

Where the class now stands, having re-walked the module rather than assuming: every name-addressed action on this path either resolves through a descriptor chain that admits only an approved inode, or addresses a random name nothing can predict, or checks what landed afterwards. The one exception is the leaf unlink, which is the residual #5430 accepted up front.

263 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean.

One self-inflicted detour worth recording because it cost a cycle: my first version of this fix put an elif after an except block, which is not valid Python. mypy caught it as a syntax error before any test ran, which is the cheapest place for that to surface, but it would not have happened at all had I written the branch out before reaching for a minimal diff.

Tally: seventeen findings -- fifteen from review, two from my own audits -- sixteen fixed, one accepted residual whose override remains a maintainer decision on this head.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 13 disposition, on 82bca7968. Real, fixed as suggested, and it reaches one surface further than I had been looking.

The finding. staged_targets() recorded the batch's identity by opening its path, and paired that with the byte total list_trash() had read from the same path earlier, under no lock. A swap in between pairs the REPLACEMENT's identity with the original's numbers -- and the delete, which faithfully checks the identity it was handed, would then destroy session data the user was never shown, on consent quantified by a different batch's size. Every finding before this was in the delete path; this one is in the approval, which is where I had stopped looking.

The fix is your wording exactly: both halves from one pinned descriptor. _approve_batch() opens the directory O_NOFOLLOW, takes the identity from fstat, builds the interior map from that descriptor, and re-reads the manifest THROUGH it rather than by path. That was affordable because _manifest_records() already accepts an open handle, so it needed an opener rather than a second parser -- _summarize_manifest() grew an optional dir_fd and nothing else changed shape. _identify_batch() is deleted rather than left beside it: two functions answering the same question is how the next drift starts.

Evidence. test_the_approval_binds_identity_and_size_to_one_directory stages a second, differently-sized batch and renames it onto the selected one's name after the listing is read, then asserts that whatever comes back has its identity and its size describing the same directory. Putting the total back to the listing's value reds it, with 66 bytes from the vanished batch attached to the impostor's identity. Mutation confirmed applied before I read the result.

264 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean.

One thing I did badly this round. Twice I made a spec edit whose old and new text differed only by a newline, which silently joined two words -- "batchesan", then "removaladdresses". I caught both by re-reading, but making the same careless mistake twice in one cycle is worth saying out loud rather than burying in a squashed commit. I switched to a script that asserts its anchor and greps for joined words afterwards, and the resulting spec diff is a pure insertion, which is what it should have been the first time.

Tally: eighteen findings -- sixteen from review, two from my own audits -- seventeen fixed, one accepted residual. That residual is the POSIX leaf unlink, and its override remains a maintainer decision on this head.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 14 disposition, on 6110e5322. Real, fixed as suggested, and it was hiding behind an earlier fix in this same PR.

The finding. Round five of the original review moved the interior DIRECTORY map from delete time to approval time, precisely because a map built at delete time records the impostor along with everything else. The FILES never made that move: _approve_batch scanned them and threw them away, so the per-file identity check compared each name against the delete's own scan -- self-consistent, authorising nothing. A listed file replaced during the handoff had its replacement's inode recorded, matched, and was unlinked.

BatchIdentity now carries files and links, and the delete demands equality of the whole map in both directions, exactly as it already did for dirs. A file added, removed or replaced since the approval is identity_changed rather than something to reconcile, and a concurrent restore lands there too -- the same answer the directories give, for the same reason.

I did weigh declining this one, and I want to show the reasoning rather than just the outcome. The memory objection behind the accepted leaf residual -- tens of MB on a 55k-session batch -- looks like it applies to storing an inode per file. But the approval ALREADY stores one per directory, and a batch of that size has directories in the same order as files. So this is a constant factor on something the PR shipped four rounds ago, not a new category of cost, and declining would have meant borrowing an argument that does not actually fit the case. That distinction is the whole reason the leaf unlink stays a residual while this does not.

Evidence. test_a_listed_file_replaced_after_approval_is_not_unlinked replaces a listed file with one of the SAME SIZE after the approval is taken, so nothing but the identity distinguishes them -- a size difference would have let a weaker check pass for the wrong reason. Removing the comparison reds it by unlinking the replacement and reporting 598 bytes freed. Mutation confirmed applied before I read the result.

265 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. The spec records it, and after last round's carelessness the spec diff is twelve added lines with nothing removed.

Tally: nineteen findings -- seventeen from review, two from my own audits -- eighteen fixed, one accepted residual. Every one since the rebase has been the same shape: an identity established, then an action or a check that trusted a name. This is the last place in the module where an approval-time map existed but was not consulted; the leaf unlink remains the one name-addressed action with no fix, and its override is a maintainer decision on this head.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 15 disposition, on 39361c7a1. Both real. One fixed as suggested, one fixed narrower than suggested with the reason.

Manifest contents not bound to approval -- fixed. Its inode was bound two rounds ago, but rewritten in place the manifest keeps that inode, every file identity still matches because no file changed, and what the rewrite alters is which files the delete believes it may unlink. BatchIdentity now carries a digest of the approved rels and a mismatch is identity_changed. I took the digest rather than the rels because it keeps the approval constant-size on a batch with six figures of entries, and a refusal does not need to name which line moved.

Writing that test corrected me on my own code, which is worth reporting. I asserted that the approval refuses a batch holding an unlisted file -- it does not; that refusal lives at delete time. So the path is SHORTER than I described when I started: nothing upstream stands in the way, and the digest is the only thing between a rewritten listing and a deleted bystander. The wrong assertion is gone and the test now says where the refusal actually is.

Unapprovable batches becoming silent successes -- fixed, narrower. You are right about the harm, and it is partly mine: last round I widened _approve_batch to also return None when the manifest cannot be summarised, which made an already-quiet path quieter. It now raises for a NAMED selection -- exactly the asymmetry the missing-id refusal ten lines above already draws.

I did not make it raise on the unnamed sweep, and I want that departure on the record rather than buried. Raising there would let one batch damaged by a crash mid-append make the whole trash un-emptyable, and this module's delete loop deliberately skips rather than aborts for that reason ("one unreadable batch must not make the whole trash un-emptyable" is in the code). Named selection is different: the caller asked for that batch and it is not going to be deleted, so silence is a lie. If you would rather the sweep also refuse, that is a product call about whether a damaged batch should block the button, and I would rather you make it than have me pick.

I also checked rather than assumed that the new raise surfaces properly: the dashboard already wraps this call in except SessionStorageError and answers 400 rather than dispatching a job, so it is a refusal on screen, not a 500.

267 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. Each mutation reds only its own test -- dropping the digest deletes the newly-listed file (606 bytes freed), dropping the raise restores the quiet success (DID NOT RAISE).

Tally: twenty-one findings, twenty fixed, one accepted residual. Eleven consecutive rounds have each found one more place where an identity was established and then a name, an inode, or now a listing was trusted instead. I am not arguing the findings are wrong -- they have all been real -- but that rate is itself information about this diff, and worth weighing against shipping the containment in stages.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 16 disposition, on fa2678a1d. Correct, and it is a bug in the fix I shipped last round rather than in older code.

The finding. I computed the digest by PATH and AFTER the interior scan. Both halves are wrong. A manifest rewritten between the scan and the digest is recorded as the NEW listing against the OLD inode maps, which authorises exactly the file the digest exists to refuse; read by path it could describe a different directory's manifest entirely. So last round's fix contained a narrower instance of the hole it closed.

It is now captured first, through batch_fd, which is your suggested fix verbatim. Ordering it first is also what makes it fail closed: a rewrite after that point leaves the digest describing the old listing, so the delete refuses rather than proceeds.

On not adding a second parser. _read_manifest() and _manifest_rels() grew the same optional dir_fd that _summarize_manifest() already had, so the pinned read has one spelling. Writing a separate fd-based rels reader would have been quicker and would have recreated exactly the duplication an earlier round of this review objected to in the link cell.

Evidence. test_a_manifest_rewritten_during_the_approval_does_not_authorize_it rewrites the manifest in place inside the approval -- in place, so its own inode never changes and no other check can catch it. Moving the capture back after the scan reds it, and reds it by deleting the smuggled file (606 bytes freed). Mutation confirmed applied before I read the result.

268 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean.

Something I should say plainly. This is the second time in this PR that a fix of mine shipped the same class of mistake it was closing. The first was reasoning about POSIX rename replacing its destination in one direction and not the other; this is reasoning about pinning a read in one place and not the other. Both times I had the mechanism right and the ordering careless. That is a pattern in how I am writing these, not two coincidences, and it is the strongest argument I can offer for the point I raised last round: twelve rounds of one-instance-at-a-time is worth weighing against landing the containment in reviewable stages, because each new guard I add is itself a candidate for this.

Tally: twenty-two findings, twenty-one fixed, one accepted residual -- the POSIX leaf unlink, whose override is a maintainer decision on this head.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 17 disposition, on 5c2c73fb2. Both real, both fixed -- and on the second one you were right to raise it again after I declined.

Final-component symlinks followed during approval -- fixed as suggested. Path.resolve() follows the last component, so a batch replaced by a symlink resolved to its TARGET and the pinned walk pinned that target: the approval recorded another directory's identity under this batch's id, and the delete, faithfully checking what it was handed, would destroy session data from outside the trash. Now only the parent is resolved and the batch's own name is re-joined, keeping O_NOFOLLOW on the component that matters.

My first test for it was worthless, and the mutation is what told me. It pointed the name at a directory with NO manifest, which the approval refuses for that reason -- so it passed with the bug present, and I would have reported it as verified had I not printed the mutated line and re-run. Pointing the name at a SECOND real batch instead leaves the resolution as the only deciding factor: with resolve() restored the approval hands back the other batch's identity, files and digest under the first batch's id.

Empty-all silently dropping unverifiable batches -- fixed, and I was wrong to frame it as a product call. I declined last round on the grounds that raising would let one crash-damaged batch make the whole trash un-emptyable. That reasoning still holds, but it was a false choice: raise-or-be-silent are not the only options. The batch now stays in the id list WITHOUT an approval, and empty_trash refuses an id that a supplied approval map does not name. It comes back as a skip the user can read, the sweep still empties everything else, and nothing needed a signature change or a decision from you.

That refusal is worth more than the reported bug. With the membership check disabled the unverified batch is DELETED -- 552 bytes freed in the test. So before this, an id reaching the worker without an approval was not merely mis-reported, it was deleted unchecked: expect.get() returning None was read as "no approval, so nothing to verify". Tightening it to "a map was supplied and this id is not in it" is the correct reading of that contract.

270 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean.

On the other red this round, with evidence rather than a shrug. Frontend Tests shard 3 reported 421 files and 6698 tests passed, 0 failed, and then died with [vitest-pool]: Worker forks emitted error. Caused by: Worker exited unexpectedly after the suite had finished -- a pool crash, not an assertion. Frontend Coverage Merge is downstream of that shard's blob report. Nothing in this round touches the frontend. The push has started a fresh run rather than a rerun, since a rerun of the superseded one is refused.

Tally: twenty-four findings, twenty-three fixed, one accepted residual.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 18 disposition, on f14769dc4. Fixed as suggested. One correction to the severity, with the evidence for it.

The finding is right about the shape. Renaming a refused object back onto the listed name writes to a name the refusal has just proved is not ours, and rename replaces its destination. That is the third time in this PR I have hit my own blind spot about that -- I reasoned about it explicitly for the manifest recovery two rounds ago and then wrote the directory rollback with the same courtesy anyway. Only a matched identity is renamed back now, which is the one case where the name belongs to the object; a mismatch, or a re-check that could not be read, leaves it under the unguessable staging name with both names logged at ERROR.

The severity is lower than stated, and I checked rather than assumed. For a DIRECTORY the described loss is not reachable: POSIX rename fails against a file (ENOTDIR) and against a non-empty directory (ENOTEMPTY), so the most the rollback can destroy is an empty directory. I wrote a test that plants a victim at the listed name to demonstrate the data loss, ran it with the rollback deliberately reinstated, and watched it pass -- because the rename failed with ENOTEMPTY exactly as POSIX says. I deleted that test rather than ship it: a test that passes with the bug present is not evidence, and here the mutation passing is the ANSWER rather than a mistake in my harness.

So the fix stands on narrower ground than "prevents data loss": it stops us writing to a name we have just disowned. The wider ground is real elsewhere and you found it first -- the same courtesy applied to the manifest, which IS a file, and there it destroyed the only copy, which is why that path now uses os.link.

Two of my own tests asserted the opposite contract -- that the intruder is renamed back and no staging debris remains -- and I have rewritten both. I want to be explicit that I changed tests rather than code to make them pass, and why that is right here: the contract they encoded was wrong. My reasoning for it at the time was "a refusal must not leave the directory under an unrecognisable name", which loses to yours. Reinstating the rollback now reds test_a_directory_swapped_after_its_identity_check_is_not_removed, so the new behaviour is pinned rather than merely asserted.

270 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean.

Also worth recording: I damaged the function mid-edit this round -- my replacement duplicated the stat block and truncated the branch above it -- and caught it by reading the region back rather than trusting the edit. mypy would have caught the syntax, but not a silently duplicated check.

Tally: twenty-five findings, twenty-four fixed, one accepted residual.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition for both advisories on ce02e930d. GPT is clean on this head with no BLOCK-MERGE marker, so nothing is blocking; these are the two CONCERNS verdicts.

First Principles: "Description contradicts the diff" -- correct, and it was the description that was wrong. A Limits bullet still claimed an explicit batch_ids request whose snapshot failed "runs without the identity check". That described a proceed-anyway branch this PR DELETES; the handler fails closed for a named selection exactly as it does for the sweep, and test_a_named_selection_fails_closed_when_the_snapshot_cannot_be_read pins it. Worth stating why this mattered more than a typo: a reviewer reading that bullet could have approved the looser behaviour it described while the code shipped the stricter one. Corrected in place, with the error named rather than quietly overwritten.

First Principles subtraction: drop _remove_scanned_dirs's bool return -- taken. Verified rather than assumed: the sole call site discards it, and the fresh post-condition scan is the arbiter of whether the batch is empty. That deleted the removed flag and five assignments to it -- bookkeeping that existed to answer a question nobody asked. The docstring said "Returns True when every one went", which was the tell.

Design: the class is closed on one of three call sites. Accurate, and the follow-up is FILED rather than promised: issue #7113 covers _discard_restored_batch and move_to_trash's empty-batch cleanup. The body now names it inline rather than describing the deferral in prose.

Design: three inline spellings of rename-verify-remove, and pinned_fs exists because that failed twice before (#2446, #2447). This is the point I want to agree with loudly rather than politely. It is the same objection an earlier round of this review made about the link cell, one level up, and it is the module's actual history: fixing the two siblings by copying this machinery a fourth time would repeat it.

I am not doing the extraction inside this PR, and the reason is not scope-protection. Shaping pinned_fs primitives around ONE consumer is how the wrong interface gets frozen; the sibling work has two more call sites, which is what shows which parts are genuinely batch-agnostic. So it is recorded as a design constraint ON #7113 -- comment 5473662224 -- including the part that is easy to get wrong (rename back only on a MATCHED identity, because restoring on a mismatch writes to a name the refusal just disowned). If you would rather see the extraction land here first, say so and I will do it here instead; that is a sequencing call and I would rather have it explicit than assume.

270 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean.

One note on the tree, since it looked alarming locally and might to a reviewer too: git diff kirocrew/main..HEAD now reports 214 files, because main has advanced 36 commits past this branch's base and renders its own newer work as deletions. The commit itself is 20 files, and GitHub diffs against the merge base, so the PR shows the real change. mergeable is still true.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 19 disposition, on f8f21e422. Real, fixed as suggested, and it is the rule this PR already documents applied one read later.

The finding. The approval binds identity, files, size and listing to one pinned directory -- and all of that can still describe the WRONG batch. A directory renamed into the selected name after list_trash() brings its own manifest, so the approval is perfectly self-consistent while authorising a batch the user never selected. The name is the only link back to the selection, and nothing was checking it. The fix I made two rounds ago bound the pair to one directory; it did not bind that directory to the choice.

Why this does not contradict "the directory is the batch's identity, not the manifest header". I checked that before implementing, because it looked like your fix asks me to trust attacker-controlled content. It does not: list_trash() ALREADY withholds a batch whose header claims a different id, on exactly this reasoning, and the rule was simply enforced at listing time only while the approval reads the manifest again afterwards. _header_names_this_batch() applies the same rule on the second read, and it only ever COMPARES -- a disagreement withholds the batch, and the header never decides what to delete. Resolving in the header's favour is the thing that rule forbids, and this is the opposite of that.

A test of mine got stronger rather than being bent to fit. test_the_approval_binds_identity_and_size_to_one_directory renames a second real batch over the selected name. It used to assert only that whatever came back had its identity and size describing ONE directory -- the impostor's -- because binding the pair was the strongest promise the code could then make. It now expects the swap to be refused outright, and disabling the comparison reds it with DID NOT RAISE. I mention the distinction because I have also rewritten tests in this PR because their contract was wrong, and this is the other kind: the contract improved.

270 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. The spec records it next to the rule it extends.

Tally: twenty-six findings, twenty-five fixed, one accepted residual. On the previous head you reported no blocking findings with no BLOCK-MERGE marker, so the leaf-unlink residual has not been re-raised and no override has been posted.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 20 disposition, on 817b622ec. Correct, and it is a gap in the check I added one head ago -- the fourth time in this PR a fix of mine needed a follow-up on its own terms.

The finding. My header comparison read isinstance(claimed, str) and claimed and claimed != name, so a header with the field stripped or emptied had "nothing to disagree with" and the swap walked through. Fail-open, and open in the direction an actor gets to choose.

It now demands equality. I checked the premise before tightening rather than after: _write_header() always writes batch_id beside schema, and a summary is only returned for the current schema, so within what reaches the approval the id is always present and its absence means the header was tampered with or truncated. There is no legitimate current-schema manifest that the stricter form refuses.

Why the leniency was there, since it explains the mistake rather than excusing it. I copied the condition from list_trash()'s existing check, which has the same shape, on the reasoning that one rule should have one spelling. That was right about the rule and wrong about the threshold: the listing decides what to OFFER and the approval decides what to DELETE, and only one of those is an authorisation boundary.

I have deliberately left list_trash() looser, and want that visible rather than discovered. A batch with a stripped header is still listed, and simply cannot be approved -- which surfaces as a refusal on a named selection and as an unreadable_batch skip on the sweep. Tightening the listing too would make such a batch vanish from the screen instead, which is a worse answer for a user whose manifest was damaged by a crash: a batch you can see and cannot delete is diagnosable, one that disappears is not. If you would rather both ends match, say so -- that is a product call about what a damaged batch should look like, not a security gap.

Evidence. test_a_header_with_no_batch_id_is_refused_rather_than_waved_through strips the field from a real batch's header and expects the approval to decline. Restoring the looser condition reds it, and reds only it -- the swap test beside it still passes, which is what shows the two guards cover different things.

271 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. The spec records the equality requirement and the deliberate divergence.

Tally: twenty-seven findings, twenty-six fixed, one accepted residual.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 21 disposition, on 4dd3055c5. Real, fixed as suggested, and it is the one place in this module that could least afford to trust a name.

The finding. scandir lists a name and the child is opened a moment later. A rename in between records the REPLACEMENT's inode in the map -- and that map is what the approval IS. So the approval blesses the impostor, and the delete, validating faithfully against it, removes it. Every other guard in this module is downstream of that map, which is what makes this the root of the set rather than one more instance of it. _scan_batch() now compares the opened descriptor's inode against entry.inode() from the listing and refuses on a mismatch.

The test needed two attempts, and the first failure is worth recording because it is the same trap as before in a new disguise. I hooked os.scandir to swap after it returned -- but the code does with os.scandir(fd) as entries: listing = list(entries), so my swap landed BEFORE the entries were materialised. Both sides then saw the impostor, agreed, and the approval succeeded: the test failed against a correct fix and would have sent me looking for a bug that was not there. Swapping inside a patched os.open puts it between the dirent and the descriptor, which is the actual window. Disabling the comparison then reds it with the impostor's inode recorded in dirs.

That is now the fourth time in this PR a hook has landed outside the interval it was aiming at. The rule I keep re-deriving: find the two operations the guard sits between and hook the SECOND one, because hooking the first usually moves the whole window rather than splitting it.

272 tests pass across test_session_storage.py and test_session_storage_api.py; flake8 / isort / black / mypy clean. The spec records the window, and the hook-placement point with it, so the next person testing this does not spend the same two attempts.

Tally: twenty-eight findings, twenty-seven fixed, one accepted residual. No override is posted; the leaf-unlink residual has not been re-raised since head ce02e930d reported clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Design disposition, on 4baca18fa. One suggestion done rather than deferred, one watch recorded where it belongs, and one item I am deliberately NOT deciding.

_dir_open_flags = pinned_fs._dir_flags -- fixed now, not at extraction time. You are right that reaching into a private is how the seam grows a second owner, and it is my line. pinned_fs.dir_flags() is public with a docstring saying why, its three internal call sites moved over, and NO back-compat alias left behind -- an alias would have been two names for one thing, which is the smaller version of the same problem. Deferring it to #7113 would have shipped the coupling in the meantime for no reason.

Ran test_pinned_staging.py alongside the storage suites for this, since pinned_fs is shared: 325 pass. I also checked the module's other consumers (apps/routes.py, eval/bench/safepath.py, snapshot.py) -- none reference the renamed helper; snapshot.py has its own local _dir_flags_nofollow, which is unrelated.

"Hold the sibling fix to the extraction" -- recorded as an instruction on #7113, comment 5474061837, in those words rather than as a suggestion. I also carried across the evidence you cited, because it is more persuasive than the principle: this PR went through several rounds of "my fix had the bug it was fixing" -- a rollback that renamed over a name it had just refused, a digest captured after the scan it was meant to precede, a header check that fail-opened on a missing field. Three of those were in guards I had just written. That is what per-call-site respelling looks like from the inside.

The Windows coarse path -- I am not ratifying this myself, and that is the point. You are asking for the raceable rmtree-behind-a-staging-rename trade to be accepted consciously at merge rather than by default, and you are right that a PR body offering a "one-line flip" is not the same as a decision. Failing closed there would refuse every Trash empty on Windows, which is a product call about whether that platform gets a working Trash or a strictly safer one -- not something I should settle by leaving the current default in place and calling it reviewed. I have put it to the maintainer explicitly rather than defaulting; whichever way it goes, it will be a decision someone made.

Everything else on this head: GPT no blocking findings with no BLOCK-MERGE, First Principles PASS, UX PASS, zero failing checks. 325 tests pass across the three suites; flake8, isort, black and mypy clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

CI red on 4baca18fa, and it was mine: Backend Tests (3.10, 3) failed test_a_listed_file_replaced_after_approval_is_not_unlinked with assert 500 == 0. Fixed on b871f9c85. The code was right; the test was flaky by construction.

What happened. The test replaces a listed file with a different one of the same size and expects the identity check to refuse the batch. It did that by unlinking the original and writing a new file at the same path -- which frees the inode, and a filesystem is entirely free to hand the same number straight back to the next allocation. CI's did. The two inode maps then matched, the delete proceeded exactly as designed, and my test failed against correct code.

Worth being clear about the direction of the error, because "CI-only failure" usually means the opposite: this was not a real defect that only manifests on CI, and it was not an infrastructure flake either. It was an assumption in my test -- that a fresh file at a reused path has a fresh inode -- that happens to hold on my filesystem and not on the runner's.

The fix is deterministic rather than retried. The replacement is now written alongside the original and renamed over it with os.replace, so both files exist at once and the inodes cannot coincide. The test also asserts the inode actually changed, so if this ever regresses it fails on its own premise rather than on the behaviour under test.

I audited the rest rather than waiting for CI to find them one at a time. Two sibling tests had the identical unlink-then-rewrite pattern and the identical latent flake -- test_a_file_substituted_at_the_manifests_name_is_not_destroyed and test_a_file_swapped_after_the_post_condition_is_not_deleted_as_debris, both of which compare inodes and nothing else. Both now rename over instead. A third, test_a_file_swapped_onto_a_scanned_link_is_not_unlinked, uses the same pattern but is NOT vulnerable: its guard also requires S_ISLNK, and a regular file fails that whatever its inode, so I left it alone rather than churning it.

I also re-ran the mutations after changing the setups, since a hardened test is a new test: disabling each guard still reds its own case and only its own. One of those reds arrived as a FileNotFoundError -- correct, since the substitute had been destroyed, but unreadable as a failure message -- so that test now asserts existence first and says "the substitute was destroyed, which is the defect itself".

325 tests pass across test_pinned_staging.py and the two storage suites; flake8, isort, black and mypy clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 22 disposition, on b44f62ed4. The finding is right and it is a consequence of my own os.link fix two rounds ago. I have fixed the hole but NOT by the suggested remedy, and I want to be explicit about that rather than quietly substituting my own.

The finding. os.link is refused outright by a filesystem without hard links, and the capability probe cannot see that -- I added os.link to _FD_SAFE_DELETE believing it covered this, but that probe tests whether the OS accepts dir_fd, not what the MOUNT supports. On such a filesystem, with a late file blocking the final rmdir, the recovery fails and the batch is left holding data with no manifest: unlistable and unrestorable, the exact loss the recovery exists to prevent. That is a real strand and it was mine.

Why I did not take the suggested fix. "Revert the fd-safe delete hunk until hard-link support is verified" would remove the descriptor-bound delete -- the entire subject of this PR and of issue #5430 -- to close a failure mode in one recovery branch. It also trades a rare strand for the original data-loss TOCTOU, which is the worse of the two. So I treated it as a correct diagnosis with a remedy scoped wider than the defect.

What I did instead. The failure is split by kind, because the two kinds mean different things:

  • EEXIST -- something arrived at the name. No fallback, and none is needed: that name holds a manifest, so list_trash() can read the batch and nothing is stranded. The debris is left for a human, which is the behaviour the link was chosen for.
  • any other OSError -- link unsupported or refused. Fall back to rename, but ONLY after confirming the destination is absent. EEXIST is the occupied case; any other error leaves occupancy unknown, and an explicit look settles it. A rename into nothing cannot destroy anything, so the no-overwrite guarantee survives the fallback.

That keeps the strong guarantee everywhere link works, removes the strand where it does not, and does not reintroduce the overwrite it was added to prevent.

Evidence, two tests for the two halves. test_a_filesystem_without_hard_links_still_gets_its_manifest_back makes os.link raise EPERM and forces the recovery by refusing the final rmdir; the manifest comes back and the debris is cleaned up. test_the_fallback_still_refuses_to_overwrite_an_arriving_manifest does the same with a file planted at the name, and the arriving bytes survive -- the fallback must not become a way back to overwriting. Removing the destination check reds the first and only the first.

327 tests pass across test_pinned_staging.py and the two storage suites; flake8, isort, black and mypy clean.

One process note: my edit inserted the two new tests in the middle of an existing one, stranding its final assertion inside mine. I caught it because the failure message named a test that was not the one failing, and put the assertion back. That is the second edit of mine this session to damage a neighbouring block -- worth saying rather than filing quietly.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT rounds 22 and 23 dispositioned together, on 991618a94. Round 22's reply was drafted and never posted, and it matters here because rounds 22 and 23 pull in OPPOSITE directions on the same six lines -- so answering them separately would have looked like I flip-flopped.

Round 22 (on b871f9c85): os.link can be refused outright, stranding the batch. Correct, and mine: I had added os.link to the _FD_SAFE_DELETE probe believing it covered this, but that probe tests whether the OS accepts dir_fd, not what the MOUNT supports. On a filesystem without hard links, with a late file blocking the final rmdir, the recovery failed and left the batch holding data with no manifest. I did NOT take the suggested remedy -- "revert the fd-safe delete hunk" would remove the subject of this PR and of #5430 to close one recovery branch, trading a rare strand for the original data-loss TOCTOU. I added a fallback instead.

Round 23 (on b44f62ed4): that fallback checked the name was free and then renamed. Also correct, and also mine: two syscalls, so a file arriving between them is replaced. It is the same trusted-a-name mistake every other guard in this PR exists to remove, and I reintroduced it while fixing something else.

Why I did not take round 23's remedy either. "Leave the debris and report incomplete" is exactly what round 22 called the loss. Taken literally the two rounds ask for opposite code, so the honest move is not to pick a side but to find the option neither names.

O_CREAT | O_EXCL is that option. The create either wins or fails with EEXIST, decided inside ONE syscall, so there is no window for anything to arrive in; and it needs no hard-link support, so it strands nothing. renameat2's RENAME_NOREPLACE would be the pure-rename equivalent but it is Linux-only and unexposed in the stdlib, which is why the fallback copies. The cost is a copy rather than a link, paid only on a filesystem without links and only when a batch has already failed to empty.

One consequence I had to handle rather than discover later: a copy can fail part way, and a manifest holding SOME of its entries is worse than none -- it lists some sessions and silently drops the rest, reading as a smaller batch rather than as damage. A failed copy now removes what it wrote and reports the failure, leaving the whole manifest recoverable as debris.

Three tests, each mutation-checked separately. Dropping O_EXCL reds the arriving-file test with our manifest's bytes where the arriving file's should be; dropping the partial-file cleanup reds the new test_a_failed_copy_back_leaves_no_half_written_manifest with "a partial manifest must not be left behind"; the strand test still passes throughout. Each mutation reds only its own case.

328 tests pass across test_pinned_staging.py and the two storage suites; flake8, isort, black and mypy clean. The spec paragraph is rewritten rather than appended to, since the mechanism it described is gone.

Emptying the Trash removed each batch with a path-resolving rmtree, so an
ancestor directory swapped to a link between selection and removal was
followed and the delete landed outside the trash. The worker also received
only batch IDs, and an id is a name: a directory moved into an approved name
was opened by that name and destroyed unapproved.

The batch is now opened by walking from the filesystem root one component at
a time with O_NOFOLLOW, each listed file is removed by (directory fd, name)
from the manifest rather than by traversal, the emptied directories go
bottom-up by descriptor including the batch through its parent's fd, and the
(st_dev, st_ino) the snapshot saw under the mutation lock is re-checked with
fstat on the opened descriptor - kept as identity_changed on mismatch.

Where the platform has neither openat nor O_NOFOLLOW the coarse rmtree stays,
with its measured byte figure and its post-condition check.

Closes #5430
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 24 disposition, on 4d3e224f3. The finding is correct, it is mine, and it is a different KIND of defect from the previous twenty-three: not a containment hole but an audit hole that my containment fix opened.

The finding. An explicit delete whose snapshot raises now returns 202 before _run_empty_job, so no session_storage.empty SEL event is written.

Why it is mine, stated precisely, because my first reading was wrong. I initially checked whether the flagged branch was in my diff, saw only the staged_targets tuple line change, and nearly answered "base-owned, zero delta". Comparing the block against kirocrew/main showed the opposite. On main this except Exception failed closed only for requested is None; an explicit selection fell through with the comment "an explicit selection needs no snapshot: the caller already named it" and DISPATCHED, which is exactly why it reached the audit inside the worker. Making both cases fail closed is the right call -- dispatching without the snapshot deletes whatever answers to the names by the time the worker runs, the loss this PR exists to prevent -- but it moved the named case off the audited path. So the delta is real and it is in the direction GPT says.

Fix. The refusal is audited where it returns, outcome refused, resources snapshot_unreadable, matching _deny's shape. Removing the call reds test_an_explicit_delete_refused_by_a_failed_snapshot_is_still_audited and nothing else. The spec paragraph that already described the cancellation now records that it is audited and why the named case needs it stated at all.

An irreversible operation that can be ATTEMPTED with no record of the attempt is a worse hole than a missing progress denominator, which is what the old code traded it for.

One adjacent gap I did NOT fold in. The four sibling _refused() 400 paths (empty_refused, cleanup_refused, restore_refused, trash_refused) emit no SEL event either. Those are byte-identical on main, are not touched by this diff, and auditing them is a policy question about whether a 400-class rejection is an event worth recording, applied across a whole handler -- not a containment fix. Recorded locally as f-20260831-41 with a recheck date rather than filed, since it is one decision covering five call sites and belongs with whoever settles that policy. Happy to be told otherwise if a reviewer wants it in scope here.

Note on my own pattern, since this is the sixth time. Each of these last few rounds found a defect inside the fix for the previous one -- rename-replaces three times, a fail-open threshold, a check-then-rename, and now a dropped audit. The common shape is that closing a hole moves control flow, and I check the new path for the property I was fixing while not re-checking the properties it used to satisfy on the way past. I have added that as a pre-push question rather than only noting it here.

329 tests pass across the three suites; flake8, isort, black and mypy clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition for the two advisory verdicts on 4d3e224f3 (Design CONCERNS, First Principles CONCERNS). Neither is blocking and neither asks me to change this diff, but both raise concerns that deserve an answer rather than silence.

1. Two sibling paths still rmtree a re-resolved path. Accepted and deferred, tracked as #7113. Both lanes independently confirmed the two call sites by reading the code -- _discard_restored_batch at session_storage.py:1380 and the move_to_trash empty-batch cleanup at :1936. That confirmation is worth more than my own description of them, so I have quoted both onto #7113 rather than leaving it as my claim.

Acted on Design's specific ask that the follow-up's priority reflect what it carries: #7113 already had security and bug, and I have dropped needs-investigation, which was true when filed and is not now -- the exposure, both sites and the mechanism are all settled, and what remains is work, not a question.

Why it stays out of this PR is unchanged and is the same reason Design gives: rename-verify-remove is spelled three times inline here, and patching these two siblings in place would make a fourth copy -- the per-call-site respelling pinned_fs exists to end after #2446 and #2447. So the follow-up is the extraction plus two call sites moved onto it, as one change.

2. The rename-verify-remove pattern is spelled three times inline. Agreed, and I want to be precise about what I am NOT claiming: this is not three coincidental similar blocks, it is one rule applied at three levels (interior dirs, batch dir, coarse path) and it should be one function. I did not extract it in this PR because the extraction's natural shape is decided by the sibling call sites in #7113 -- extracting against three call sites now and then reshaping it when two more arrive is worse than extracting once against five. Named on #7113 so the extraction is the unit of work there rather than an afterthought.

3. The Windows coarse path should be ratified by a human at merge, not inherited by default. Agreed, and deliberately not settled by me. The trade is stated plainly so it can be decided rather than discovered: the coarse path renames the batch to .<batch-id>.removing-<random>, verifies the renamed directory's identity, and removes it under that name, which leaves a small guessable window that a descriptor-bound walk would not have. Failing closed instead is a one-line flip and would refuse EVERY Trash empty on a platform without the descriptor primitives -- a working feature traded for a narrower window. That is a product call about Windows users, not a code-quality call, so it is put to the maintainer and I will implement whichever way it goes. It is the one open item on this PR.

Everything else in both reviews is recorded as justified, including the two changes from the last rounds (named selection failing closed on an unreadable snapshot, and the refused empty now being SEL-audited).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session trash: harden the empty against an ancestor swap, and check batch identity across the handoff

2 participants