Skip to content

fix(session-store): the write edge reports an A, B, A instead of reporting it as promoted (#390) - #410

Merged
edgehero merged 1 commit into
mainfrom
fix/promote-aba-390
Sep 23, 2026
Merged

edgehero merged 1 commit into
mainfrom
fix/promote-aba-390

Conversation

@edgehero

@edgehero edgehero commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

The read path answers a key directory swapped under a running job with two checks that between them cover
every ordering: it stages the transcript from one DESCRIPTOR, so bytes cannot come from a file that replaced
the name afterwards, and it re-checks the name by path after the copy, so a swap that happened before the
open is caught too.

The write path has neither. Node exposes no openat/renameat, so promoteSession re-checks the key
directory's dev:ino under the lock and again after the rename. Both catch a swap that is STILL STANDING.
Neither can see one that is put back:

promote begins        key dir is real, its dev:ino recorded
attacker swaps in     key dir is now a symlink to somewhere else
  ... the transcript copy, the rename and the venue sentinel all land there ...
attacker swaps back   key dir is the real one again
post-swap re-check    dev:ino matches, so the promotion reports promoted: true

Measured here with a deterministic probe: {"promoted":true,"reason":"promoted","bytes":60}, the transcript
in the attacker's directory, and the real key holding only pi-version, resume-chain and venue. The
next job on that key cold-starts as absent while the record says the work was promoted, which is the
silent no-op CLAUDE.md calls the worst outcome available.

What the shape comparison cannot see, the inode can

rename PRESERVES the inode. So the transcript's identity is read from the temp before the rename and
compared against what the canonical name holds after it. If the rename landed where it was meant to, that is
the same file. If the directory was swapped for the rename and swapped back afterwards, the canonical name
resolves into the real key again and holds its OLD transcript, or nothing -- a different inode either way,
and a fact that is still true once the shape is right again.

Detection, not prevention, like the check beside it: the bytes are already wherever the name pointed and
nothing host-side can recall them. What changes is that the record stops claiming otherwise. The new token
is transcript-diverted, a closed-enum addition on the promote path, because key-not-a-directory says the
name is the wrong SHAPE and by this point it is not.

The fail-closed arm is deliberate and pinned: if BOTH identity reads fail, a bare comparison is null !== null, which is false, so the promotion would report success having verified nothing. Two failed reads are
the one case where "they match" and "I could not tell" look alike.

The sidecar writes after the rename deliberately get no equivalent check. The transcript's own identity
is what decides whether the promotion landed; a sidecar diverted by the same swap is covered by the same
refusal, and a second identity dance per sidecar would buy nothing the first does not already say.

The residual this leaves, which a review pass found and the contract now states

A swap put back FOR THE DURATION OF A CHECK and re-applied afterwards defeats any check that resolves the
name again. An A, B, A, B around the two post-rename checks still reports promoted: true with the
transcript outside the store -- and on a key that already held one, the next job RESUMES the superseded
transcript rather than cold-starting, so the lost turn is invisible on both edges. Every path-based check
has this shape; closing it needs openat/renameat, not a third check. A 45 second unsynchronised live
race over 215,372 promotions against 105,888 swap cycles hit it 0 times, so it is a deterministic-window
finding of exactly the class this issue was. INT-SESSION-STORE-CONTRACT says so, and its residual count
moves from two to three.

One assumption is added and named, because it is the first in that file to rest on it: that rename
PRESERVES the inode. It does on every filesystem this project supports. On one that derives inode numbers
from the path (SMB/CIFS without serverino, some FUSE) every promotion would refuse with a token that reads
as an attack, which is the same class as the O_EXCL semantics the lock already assumes.

Tests

Six mutations, all red, and the sixth is the one a review pass found surviving: a check that asks "is
anything at the name" instead of "is it MY file". Against a FRESH key those are indistinguishable, because
after the revert the canonical name holds nothing -- so there is a third test on a key that ALREADY holds a
transcript, which is the case the issue asked to be driven and the one where the lie is worst. The A, B, A is driven by injected fs calls rather than by a sleep, because the
window is two calls wide and a timing test cannot hit that ordering: the issue's own 45 second unsynchronised
race did not hit it once in 16,591 promotions.

Getting the fixture wrong is instructive enough to be written on the test. The swap has to go in before the
COPY, not before the rename: the .incoming temp is created inside the key directory, so a swap after it
exists carries the temp away with the real directory and the rename fails with ENOENT -- a refusal, but the
wrong one, and not the lie under test. A second test pins that an ordinary promotion still passes, without
which a check that refused everything would look identical.

INT-SESSION-STORE-CONTRACT amended, and the sentence that said this edge "has no answer to it" now names
the answer. DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED UNCHANGED, checked: the key is still a derived hash,
which is what makes the path precomputable and therefore what this defends.

Precondition unchanged: write access to PI_SESSIONS_DIR, which is what the whole store already
concedes.

No version moves.

Closes #390

…rting it as promoted (#390)

The read path answers a key directory swapped under a running job with two checks that between them cover
every ordering: it stages the transcript from one DESCRIPTOR, so bytes cannot come from a file that replaced
the name afterwards, and it re-checks the name by path after the copy, so a swap that happened before the
open is caught too.

The write path has neither. Node exposes no `openat`/`renameat`, so `promoteSession` re-checks the key
directory's `dev:ino` under the lock and again after the rename. Both catch a swap that is STILL STANDING.
Neither can see one that is put back:

```
promote begins        key dir is real, its dev:ino recorded
attacker swaps in     key dir is now a symlink to somewhere else
  ... the transcript copy, the rename and the venue sentinel all land there ...
attacker swaps back   key dir is the real one again
post-swap re-check    dev:ino matches, so the promotion reports promoted: true
```

Measured here with a deterministic probe: `{"promoted":true,"reason":"promoted","bytes":60}`, the transcript
in the attacker's directory, and the real key holding only `pi-version`, `resume-chain` and `venue`. The
next job on that key cold-starts as `absent` while the record says the work was promoted, which is the
silent no-op `CLAUDE.md` calls the worst outcome available.

## What the shape comparison cannot see, the inode can

`rename` PRESERVES the inode. So the transcript's identity is read from the temp before the rename and
compared against what the canonical name holds after it. If the rename landed where it was meant to, that is
the same file. If the directory was swapped for the rename and swapped back afterwards, the canonical name
resolves into the real key again and holds its OLD transcript, or nothing -- a different inode either way,
and a fact that is still true once the shape is right again.

**Detection, not prevention**, like the check beside it: the bytes are already wherever the name pointed and
nothing host-side can recall them. What changes is that the record stops claiming otherwise. The new token
is `transcript-diverted`, a closed-enum addition on the promote path, because `key-not-a-directory` says the
name is the wrong SHAPE and by this point it is not.

The fail-closed arm is deliberate and pinned: if BOTH identity reads fail, a bare comparison is `null !==
null`, which is false, so the promotion would report success having verified nothing. Two failed reads are
the one case where "they match" and "I could not tell" look alike.

**The sidecar writes after the rename deliberately get no equivalent check.** The transcript's own identity
is what decides whether the promotion landed; a sidecar diverted by the same swap is covered by the same
refusal, and a second identity dance per sidecar would buy nothing the first does not already say.

## The residual this leaves, which a review pass found and the contract now states

A swap put back FOR THE DURATION OF A CHECK and re-applied afterwards defeats any check that resolves the
name again. An A, B, A, B around the two post-rename checks still reports `promoted: true` with the
transcript outside the store -- and on a key that already held one, the next job RESUMES the superseded
transcript rather than cold-starting, so the lost turn is invisible on both edges. Every path-based check
has this shape; closing it needs `openat`/`renameat`, not a third check. A 45 second unsynchronised live
race over 215,372 promotions against 105,888 swap cycles hit it 0 times, so it is a deterministic-window
finding of exactly the class this issue was. `INT-SESSION-STORE-CONTRACT` says so, and its residual count
moves from two to three.

One assumption is added and named, because it is the first in that file to rest on it: that `rename`
PRESERVES the inode. It does on every filesystem this project supports. On one that derives inode numbers
from the path (SMB/CIFS without `serverino`, some FUSE) every promotion would refuse with a token that reads
as an attack, which is the same class as the `O_EXCL` semantics the lock already assumes.

## Tests

Six mutations, all red, and the sixth is the one a review pass found surviving: a check that asks "is
anything at the name" instead of "is it MY file". Against a FRESH key those are indistinguishable, because
after the revert the canonical name holds nothing -- so there is a third test on a key that ALREADY holds a
transcript, which is the case the issue asked to be driven and the one where the lie is worst. The A, B, A is driven by injected fs calls rather than by a sleep, because the
window is two calls wide and a timing test cannot hit that ordering: the issue's own 45 second unsynchronised
race did not hit it once in 16,591 promotions.

Getting the fixture wrong is instructive enough to be written on the test. The swap has to go in before the
COPY, not before the rename: the `.incoming` temp is created inside the key directory, so a swap after it
exists carries the temp away with the real directory and the rename fails with ENOENT -- a refusal, but the
wrong one, and not the lie under test. A second test pins that an ordinary promotion still passes, without
which a check that refused everything would look identical.

`INT-SESSION-STORE-CONTRACT` amended, and the sentence that said this edge "has no answer to it" now names
the answer. `DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED` UNCHANGED, checked: the key is still a derived hash,
which is what makes the path precomputable and therefore what this defends.

**Precondition unchanged**: write access to `PI_SESSIONS_DIR`, which is what the whole store already
concedes.

No version moves.

Closes #390

Signed-off-by: Rob Boerman <robboerman@live.nl>
@edgehero
edgehero merged commit 4c6c73d into main Sep 23, 2026
6 checks passed
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.

The session store's write edge cannot see an A,B,A on the key directory, and reports promoted: true

1 participant