Skip to content

Commit 611be47

Browse files
TheWayWithinclaude
andcommitted
fix(editor): serialise save and seal — the threading fix could seal an unvalidated register
A second adversarial review, aimed at the previous round's fixes, found that ThreadingHTTPServer (added there so a long 1Password pull could not block Save or Cancel) turned the register file into shared mutable state. setup.sh validates the register and encrypts it in two separate passes. A /save arriving between them was invisible to the validator: /seal still returned ok, the ceremony still showed three shares, and the owner still walked away with a printed, share-split Executor File containing a register that never held their assets. Measured: 238 bytes against a 2646-byte control. - One RLock now spans the whole of /save and the whole of /seal, plus /load and the dedup scan that read the same file. The browser disables Save for the duration too. - Each save writes its own temp name and unlinks it on failure. Two concurrent saves used to share target + ".tmp" and race os.replace, so the winner renamed the LOSER's bytes into place and returned 200 to the wrong request. - The suite proves the invariant by timing, and the test was checked for teeth: with the lock neutered it fails, sealing a 208-byte invalid register. - setup.sh gains a timeout; one 1Password pull at a time. Also from the same review: - The honesty gate could not be cleared. Judging "untouched action" field by field left an owner who rewrote the note but kept an action that was simply right warned forever — and notify-only is right for most of a real vault, so a 563-item import would have made the warning permanent noise, which is how a gate teaches people to click through it. Engagement is now per card: the first edit to any field clears it. - \b was the wrong boundary for vault names: "_" is a word character, so Work_Vault and Teams matched nothing and defaulted to TICKED — pulling a colleague's accounts into a personal estate register. Bounded on letters now, with an optional trailing s. Homework and Framework stay ticked. - Declining the gate said nothing, so the button looked broken; the zero-match screen claimed items did not exist when they were merely hidden; the wizard's "transfer" wording named a beneficiary the wizard never asks for. Suite 166 -> 169 pass / 0 fail (167/0 under expect), shellcheck clean. Spec §12.2 records the round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3fb4842 commit 611be47

6 files changed

Lines changed: 298 additions & 50 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ All specs live in `ideation/` (moved from repo root, 17 Jul 2026):
8888
matching. Every one lived in the seam between the importer and
8989
something that already existed, not in the import logic where the
9090
tests were densest.
91+
A **second** review, aimed at those fixes, found that the
92+
`ThreadingHTTPServer` change had made the register file shared mutable
93+
state (spec §12.2): a **Save landing inside a Seal made setup.sh
94+
validate one register and encrypt another**`/seal` returned ok, the
95+
ceremony ran, and the owner got printed shares to a 238-byte file that
96+
never held their assets. Now one `RLock` spans save/seal/load, each
97+
save writes its own temp file, and the suite proves the invariant by
98+
timing (verified to FAIL with the lock removed). Also: the honesty
99+
gate could never be cleared once bulk-imported (cry-wolf), and `\b`
100+
matched vault names wrongly so `Work_Vault` and `Teams` defaulted to
101+
ticked. Suite **169 pass / 0 fail** (167/0 under expect).
91102
- **Pickup sequence (agreed 29 Jul 2026; P1 and P2 now done):**
92103
1. ~~EF-ISS-8 **P1** — pull + mapper + stub-`op` tests.~~ DONE 29 Jul.
93104
2. ~~**P2** — editor integration.~~ DONE 10 Aug.

ideation/1password-import-spec.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,54 @@ between the importer and something that already existed** — the seal
452452
path, review mode, the notes field, the executor's reading experience.
453453
None of them were in the import logic itself, which is where the tests
454454
were densest.
455+
456+
### 12.2 Second review round: the threading fix bit back (2026-08-10)
457+
458+
A second independent pass, briefed to attack §12.1's fixes rather than the
459+
original build, found that **the `ThreadingHTTPServer` change in §12.1(6)
460+
introduced the worst defect in the whole feature**, and proved it end to
461+
end. Recorded because the lesson is general: a fix aimed at responsiveness
462+
turned a serialised file into shared mutable state.
463+
464+
1. **A Save landing inside a Seal seals a register nobody validated.**
465+
`setup.sh` validates the file and encrypts it in two separate passes.
466+
Once the server was threaded, a `/save` arriving between those passes
467+
was invisible to the validator: `/seal` still returned `ok`, the
468+
ceremony still displayed three shares, and the owner still walked away
469+
with a printed, share-split Executor File — containing a register that
470+
never held their assets. Measured at 238 bytes against a 2646-byte
471+
control. One `RLock` now spans the whole of `/save` and the whole of
472+
`/seal` (and `/load` and the dedup scan, which read the same file),
473+
the browser disables Save for the duration, and the suite proves the
474+
invariant by timing. **The test was checked for teeth**: with the lock
475+
neutered it fails, sealing a 208-byte invalid register.
476+
2. **Two concurrent saves swapped bodies.** Both wrote the same
477+
`target + ".tmp"` and raced `os.replace`; the winner renamed the
478+
*loser's* bytes into place and returned 200 to the wrong request. Each
479+
save now writes its own temp name, unlinks it on failure (a leftover
480+
would be a plaintext register fragment), and takes the lock.
481+
3. **The honesty gate could not be cleared.** Judging "untouched action"
482+
field by field meant an owner who rewrote the note but kept an action
483+
that was simply right stayed warned forever — and `notify-only` is
484+
right for most of a real vault, so on a 563-item import the warning
485+
would have become permanent noise, which is how a gate teaches people
486+
to click through it. Engagement is now tracked per card: the first
487+
edit to any field removes the stamp.
488+
4. **`\b` was the wrong boundary for vault names.** `_` is a word
489+
character, so `\bwork\b` missed `Work_Vault` and `\bteam\b` missed
490+
`Teams` — and those default to *ticked*, which pulls a colleague's
491+
accounts into a personal estate register. Now bounded on letters, with
492+
an optional trailing `s`. "Homework" and "Framework" stay ticked.
493+
5. Smaller: the zero-match screen said "nothing matches" for items that
494+
were merely hidden, and offered no way to reveal them; declining the
495+
honesty gate said nothing at all, so the button looked broken;
496+
`setup.sh` had no timeout, which after the threading change left a
497+
permanently disabled Seal button; and the wizard's `transfer` wording
498+
named a beneficiary the wizard never asks for, so the executor would
499+
have been told to transfer an asset to nobody in particular.
500+
501+
**Two rounds, two lessons.** The first: the defects were in the seams
502+
between the importer and what already existed. The second: the *fixes*
503+
were where the next defects lived, and the most dangerous one came from
504+
the most innocuous-looking change in the round. Both rounds were found by
505+
briefing a fresh reviewer from this spec rather than from the build.

tests/run-tests.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,16 @@ edcheck 'importGate("Save and finish anyway?")' "review-mode Save goes through i
644644
edcheck "dataset.importedAction" "bulk drafts are stamped so the gate sees an untouched action"
645645
edcheck "impKeys" "the wizard is keyboard-reachable"
646646
edcheck "Nothing matches" "a filter matching nothing does not end the wizard"
647+
edcheck "delete card.dataset.importedAction" "editing an imported entry clears the warning"
648+
# The register file is shared mutable state now the server is threaded. A
649+
# Save landing inside a Seal makes setup.sh validate one register and encrypt
650+
# another — proven possible with the lock removed.
651+
grep -q "file_lock = threading.RLock()" "$ROOT/web/edit-server.py" \
652+
&& ok "the register file is guarded by a lock" \
653+
|| fail "the save/seal lock is gone — a Save can be sealed unvalidated"
654+
grep -q 'tmp = "%s.tmp.%d" % (target, threading.get_ident())' "$ROOT/web/edit-server.py" \
655+
&& ok "each save writes its own temp file" \
656+
|| fail "concurrent saves share one temp name and can swap bodies"
647657
edcheck 'body: buildYAML()})' "sealing saves what is on screen first"
648658
# The wizard must not seed the imported marker as the owner's own words: it
649659
# would be sealed and read by the executor as a to-do for someone who has died.

tests/test-edit-server.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010
Exit 0 = all good; prints a diagnostic and exits 1 on any failure.
1111
"""
1212

13+
import glob
1314
import json
1415
import os
16+
import shutil
1517
import subprocess
1618
import sys
1719
import tempfile
20+
import threading
1821
import time
1922
import urllib.request
2023

@@ -245,6 +248,93 @@ def check_import_endpoint(d):
245248
print("ok: import endpoint pulls metadata only, dedups, and fails cleanly")
246249

247250

251+
def check_save_seal_serialised(d):
252+
"""setup.sh validates the register and encrypts it in two separate passes.
253+
A /save landing between them would be sealed WITHOUT ever being validated
254+
— the owner would walk away with printed shares to a file that does not
255+
hold their assets, and nothing would say so. The server must therefore
256+
hold one lock across the whole of /save and the whole of /seal.
257+
Proven by timing rather than by decrypting: the save must not complete
258+
until the seal has."""
259+
target = os.path.join(d, "race.yaml")
260+
with open(EXAMPLE) as f:
261+
original = f.read()
262+
open(target, "w").write(original)
263+
proc, url = start(target, mode="create")
264+
265+
took = {}
266+
267+
def seal():
268+
t0 = time.time()
269+
try:
270+
took["seal_body"] = post(url + "seal", json.dumps({"overwrite": True}))
271+
finally:
272+
took["seal"] = (t0, time.time())
273+
274+
th = threading.Thread(target=seal)
275+
th.start()
276+
time.sleep(0.5) # let setup.sh get past its validation pass
277+
t0 = time.time()
278+
post(url + "save", SAVE_BODY) # a valid but completely different register
279+
took["save"] = (t0, time.time())
280+
th.join(120)
281+
if th.is_alive():
282+
fail("the seal never returned", proc)
283+
284+
if not json.loads(took["seal_body"]).get("ok"):
285+
fail(f"the seal itself failed: {took['seal_body'][:200]}", proc)
286+
if took["save"][1] < took["seal"][1]:
287+
fail("a Save completed while a Seal was running — setup.sh can then "
288+
"validate one register and encrypt another")
289+
# And the register on disk is exactly one of the two, never a blend.
290+
on_disk = open(target).read()
291+
if on_disk != SAVE_BODY and on_disk != original:
292+
fail("the register on disk is neither of the two bodies written")
293+
if glob.glob(target + ".tmp*"):
294+
fail("a plaintext .tmp fragment was left beside the register")
295+
296+
post(url + "cancel")
297+
wait_exit(proc, 2)
298+
print("ok: a Save cannot land inside a Seal, and leaves no plaintext fragment")
299+
300+
301+
def check_concurrent_saves(d):
302+
"""Two saves must not swap bodies: with one shared temp filename the
303+
winner renames the loser's bytes into place and still returns 200."""
304+
target = os.path.join(d, "twosaves.yaml")
305+
with open(EXAMPLE) as f:
306+
open(target, "w").write(f.read())
307+
proc, url = start(target, mode="create")
308+
309+
bodies = [SAVE_BODY.replace("Rev Tester", "Writer One"),
310+
SAVE_BODY.replace("Rev Tester", "Writer Two")]
311+
errors = []
312+
313+
def save(b):
314+
try:
315+
post(url + "save", b)
316+
except Exception as e: # noqa: BLE001 - reported below
317+
errors.append(str(e))
318+
319+
threads = [threading.Thread(target=save, args=(b,)) for b in bodies]
320+
for t in threads:
321+
t.start()
322+
for t in threads:
323+
t.join(30)
324+
325+
if errors:
326+
fail(f"a concurrent save errored instead of being serialised: {errors}", proc)
327+
on_disk = open(target).read()
328+
if on_disk not in bodies:
329+
fail("two concurrent saves produced a register that is neither of them", proc)
330+
if glob.glob(target + ".tmp*"):
331+
fail("a plaintext .tmp fragment was left beside the register", proc)
332+
333+
post(url + "cancel")
334+
wait_exit(proc, 2)
335+
print("ok: concurrent saves are serialised and cannot blend registers")
336+
337+
248338
def main():
249339
if not os.path.isfile(SERVER):
250340
fail(f"server not found at {SERVER}")
@@ -330,6 +420,13 @@ def main():
330420
else:
331421
print("note: create+seal path skipped (age/ssss not installed)")
332422

423+
# ---- threading safety around the register file ----
424+
check_concurrent_saves(d)
425+
if shutil.which("age") and shutil.which("ssss-split"):
426+
check_save_seal_serialised(d)
427+
else:
428+
print("note: save-during-seal race check skipped (age/ssss not installed)")
429+
333430
# ---- 1Password import endpoint (EF-ISS-8 P2) ----
334431
check_import_endpoint(d)
335432

web/edit-server.py

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
EDITOR_HTML = os.path.join(HERE, "editor.html")
3434
IDLE_TIMEOUT = 3600 # give up after an hour untouched
3535
IMPORT_TIMEOUT = 180 # the owner may be staring at 1Password's approval dialog
36+
SEAL_TIMEOUT = 600 # generous: setup.sh runs the whole proof chain
3637

3738
# scripts/import-1password.sh exit code -> (machine code, plain-English line).
3839
# The script's own stderr carries the detail and is passed through verbatim.
@@ -71,6 +72,15 @@ def main() -> int:
7172

7273
REPO_ROOT = os.path.dirname(HERE)
7374
state = {"result": None}
75+
# The server is threaded so a long 1Password pull cannot block Save or
76+
# Cancel — but that makes the register file shared mutable state across
77+
# threads. Everything that reads or writes it takes this lock. Without it
78+
# a Save landing inside a Seal is catastrophic and silent: setup.sh
79+
# validates the file and encrypts it in two separate passes, so it will
80+
# happily seal a register that never passed validation, and the owner
81+
# walks away with shares to a file that does not hold their assets.
82+
file_lock = threading.RLock()
83+
import_busy = threading.Lock()
7484

7585
def run_validate():
7686
p = subprocess.run(
@@ -93,10 +103,15 @@ def run_seal(overwrite):
93103
env = dict(os.environ, EXECUTOR_FILE_EMIT="1")
94104
# setup.sh is a bash script (set -o pipefail): invoke it via its own
95105
# shebang, not `sh` — Ubuntu's /bin/sh is dash and rejects pipefail.
96-
p = subprocess.run(
97-
[os.path.join(REPO_ROOT, "scripts", "setup.sh"), target],
98-
stdin=subprocess.DEVNULL, capture_output=True, text=True, env=env,
99-
)
106+
try:
107+
p = subprocess.run(
108+
[os.path.join(REPO_ROOT, "scripts", "setup.sh"), target],
109+
stdin=subprocess.DEVNULL, capture_output=True, text=True, env=env,
110+
timeout=SEAL_TIMEOUT,
111+
)
112+
except subprocess.TimeoutExpired:
113+
return {"ok": False, "error": "sealing did not finish in time; "
114+
"nothing was changed. Try again."}
100115
if p.returncode != 0:
101116
msg = (p.stderr or p.stdout or "seal failed").strip()
102117
return {"ok": False, "error": msg[-800:]}
@@ -148,7 +163,8 @@ def flush():
148163
assets.append(dict(cur))
149164
cur.clear()
150165

151-
with open(target, encoding="utf-8", errors="replace") as f:
166+
# Under the lock: a Save could be replacing this file underneath us.
167+
with file_lock, open(target, encoding="utf-8", errors="replace") as f:
152168
for line in f:
153169
if re.match(r"^[A-Za-z_]+:", line):
154170
flush()
@@ -255,30 +271,49 @@ def do_GET(self):
255271
self._send(200, body, "text/html; charset=utf-8")
256272
elif self.path == "/load":
257273
data = b""
258-
if os.path.isfile(target):
259-
with open(target, "rb") as f:
260-
data = f.read()
274+
with file_lock:
275+
if os.path.isfile(target):
276+
with open(target, "rb") as f:
277+
data = f.read()
261278
self._send(200, data)
262279
elif self.path == "/validate":
263280
self._json(run_validate())
264281
elif self.path.split("?", 1)[0] == "/import/1password":
265282
q = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
266-
self._json(run_import(q.get("vaults", []) == ["1"],
267-
[v for v in q.get("vault", []) if v.strip()]))
283+
# One pull at a time. Two would mean two 1Password approval
284+
# dialogs racing each other in front of a confused owner.
285+
if not import_busy.acquire(blocking=False):
286+
self._json({"ok": False, "code": "busy",
287+
"message": "A pull is already running.",
288+
"detail": "Wait for it to finish, or approve it in 1Password."})
289+
return
290+
try:
291+
self._json(run_import(q.get("vaults", []) == ["1"],
292+
[v for v in q.get("vault", []) if v.strip()]))
293+
finally:
294+
import_busy.release()
268295
else:
269296
self._send(404, b"not found")
270297

271298
def do_POST(self):
272299
length = int(self.headers.get("Content-Length", 0))
273300
body = self.rfile.read(length) if length else b""
274301
if self.path == "/save":
275-
try:
276-
tmp = target + ".tmp"
277-
with open(tmp, "wb") as f:
278-
f.write(body)
279-
os.replace(tmp, target)
280-
except OSError as e:
281-
self._send(500, str(e).encode()); return
302+
# Its own temp name per request, and the lock, so two saves
303+
# cannot swap bodies: without both, the request that gets the
304+
# 200 is not necessarily the one whose bytes landed.
305+
tmp = "%s.tmp.%d" % (target, threading.get_ident())
306+
with file_lock:
307+
try:
308+
with open(tmp, "wb") as f:
309+
f.write(body)
310+
os.replace(tmp, target)
311+
except OSError as e:
312+
try:
313+
os.unlink(tmp) # never leave plaintext behind
314+
except OSError:
315+
pass
316+
self._send(500, str(e).encode()); return
282317
# In create mode the server stays up so the owner can seal;
283318
# in review mode saving is the end and review.sh takes over.
284319
if mode == "create":
@@ -290,7 +325,11 @@ def do_POST(self):
290325
params = json.loads(body) if body else {}
291326
except ValueError:
292327
params = {}
293-
self._json(run_seal(bool(params.get("overwrite"))))
328+
# Held for the whole ceremony: setup.sh validates and encrypts
329+
# in separate passes, and a Save landing between them would be
330+
# sealed without ever being validated.
331+
with file_lock:
332+
self._json(run_seal(bool(params.get("overwrite"))))
294333
elif self.path == "/done":
295334
self._send(200, b"done"); self._stop("saved")
296335
elif self.path == "/cancel":

0 commit comments

Comments
 (0)