fix(security): 14 rounds of adversarial testing — 318 holes closed, 1052 regression assertions - #112
Merged
Merged
Conversation
Four offensive agents attacked the hub's trust boundaries in isolated worktrees; every finding is a Go test that failed on the tree before the fix and passes after. 43 TestSec_* regression tests land with the fixes. The two that matter most: - A grant outlived org membership: projectPerm consulted p.Perms before checking the org role, so removing someone from the org through the API left their explicit project grant working. Offboarding did not offboard. - Any device could PUT any other device's journal key — the hub never compared the key to X-Bdrive-Device. That is the "each device writes only its own journal" invariant, enforced nowhere. Also: uploads accepted .bdrive/ and .git/ paths (and materialize applied only filter.Skip, never neverSync, so a hostile peer journal could too); the org share audit handed every member public /s/ URLs for projects they were denied; password reset left old sessions and device tokens valid; blobs were not verified against their content address; quota was bypassed by chunked encoding and by a client-declared size; X-Forwarded-For defeated both rate limiters, login brute-force included; the /s/* sandbox CSP was missing on error paths; expired shares were revocable by anyone; and projectPerm failed open to admin for org-less and unknown projects. Known open, recorded in .claude/security-goal.md: the device header is self-asserted (the fix buys an audit trail, not identity), and the Dir==nil/Auth==nil admin escape needs a design decision first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 2 attacked the five boundaries round 1 never reached (read heat, path handling, secret leakage, the agent hook guard, the metadata store) plus the places round 1's coverage was overstated. 43 more regression tests; 86 TestSec_* now green. The critical one was not on the original board. A journal op's Blob field is a raw storage key that nothing validates: handleStorePut checks blob keys and their content hash, but a journal is arbitrary JSONL that never passes through that validator. One PUT of your own journal with "blob":"../../<other-project>/blobs/<sha>" — or "../../../etc/passwd" — then reads back through the ordinary /file, /download and /render routes. Any member read any file on the hub host, across orgs and outside the storage root. Guarded where content resolves, and localBackend now refuses a key that escapes root at all. Also: /blob served HTML and SVG inline on the hub origin with no sandbox CSP (stored XSS via History); bdrive init deleted the hooks it had just written whenever $HOME is a git repo, silently disabling sync hooks machine-wide; any account could rewrite another org's device registry row and forge History attribution; a planted device id became a heat "reader", putting an identity in an API response that must never carry one; storage errors relayed the hub's absolute paths (and on S3 the bucket and key); /auth/login?next= was an open redirect via backslash and TAB; the org and project registries handed out their live maps (self-promotion to owner, plus a hub-killing concurrent map iteration); revoked invites came back after a restart and refused writes applied in memory anyway; a share minted by someone since removed from the org kept serving publicly; a newline in a folder name made the hook guard spawn bdrive outside any mount; single-volume upload escaped through a symlink; the seat check was check-then-act; and the hub data dir holding auth.json ended up 0755. Known open and recorded in .claude/security-goal.md: nothing expires (no TTL on tokens or sessions), the Dir==nil/Auth==nil admin escape, and client-asserted Op.User. Postgres was never exercised — row 14's SQL-injection result covers file and sqlite only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 3 attacked the journal fields nobody had audited, the fixes rounds 1 and 2 had just landed, the real `bdrive serve -c` config path, and the read ledger on a live Postgres. 133 TestSec_* now green. The two worst are in the syncer, and both are arbitrary file write on every teammate's machine. materialize guarded Path with neverSync, which splits on "/" and looks segments up in ReservedDirs — ".." is not a member and no ignore rule mentions it, so one JSONL line pushed to your own journal key reached ~/.ssh/authorized_keys on every device that synced. And the reserved-directory guard was case-sensitive, so .GIT/hooks/pre-commit sailed past it and APFS resolved it into the real .git/hooks. Op.Mode was applied verbatim, setuid bit included. Attacking our own round-2 fixes paid for the slot twice over: ownsDevice turned out to be a one-request speed bump (the refused report is what registered the forged id, so the second identical request passed) and its first-caller-wins rule made device registration a claim-staking primitive an outsider could use to forge History attribution and silently kill the real owner's read heat forever. Both dissolve by keying the registry on (account, id) instead of treating the client's device header as a hub-wide namespace. trust_proxy, added in round 2, took the first X-Forwarded-For hop — but XFF grows left-to-right, so turning it on disabled the login brute-force limiter instead of fixing it. Also: a peer's Lamport: MaxInt64 wrapped a victim's clock and silently reverted its own edits on its own disk; History leaked other orgs' device names and was a hub-wide device-existence oracle; anonymous /api/config named the storage bucket; /auth/reset enumerated accounts by timing and was not rate limited; the signed-in hub UI was frameable and sniffable; an asset miss returned the app shell marked immutable for a year; Op.Size forged Content-Length; a share on an org-less project survived its creator's offboarding; a refused upload still created directories outside the served folder; and one NUL in a read report wedged the entire hub's read telemetry permanently on Postgres. Verified against a real Postgres 16 this round. Known open and recorded in .claude/security-goal.md: NUL round-trip on Postgres text columns (refusal vs encoding is a design call), nothing expires, the Dir==nil/Auth==nil escape, client-asserted Op.User, and store/sign on a backend that can actually presign — never reached in three rounds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 4 opened five packages no previous round had touched — store, journal, config, remote and cmd/bdrive — and attacked the fixes rounds 1-3 had landed. It broke five of them. 166 TestSec_* now green. The four criticals: - ownJournal bound the journal key to the X-Bdrive-Device header of the same request, and nothing bound that header to an account. Round 1's test varied the key while holding the header fixed — the one combination that was refused. Move both together and any member wrote and replaced any peer's journal: her ops vanish, every device replays the attacker's deletes, History credits them to her. - store.BlobPath joined Op.Blob straight onto the blob dir with no check that it was a sha256, and HasBlob answering true made syncer.pull skip hash verification — so a peer's journal op read any file on every teammate's machine into their working folder. - A folder's .bdrive/config.json chose where this device's hub token was sent. The file travels with the folder, so a zip or a colleague's copy redirected the credential to any host, http:// included. - sync --prune read .bdriveignore before the cycle and pruned against the version the cycle had just pulled, so it deleted for the whole team under exactly the ! rules it refuses to run with. A teammate running bdrive scope was enough; no attacker needed. Also: two symlink escapes in materialize (unsafeRel judges spelling, not disk); three ways one peer op killed sync permanently on every device that pulled it, including a panic on a short blob string; the ignore-file reload dropped the nested-mount boundary; round 3's Lamport ceiling was inclusive and so still reachable; the (account, id) device rekey held neither on the read path nor on disk, so a restart handed the device to the squatter; presigned uploads bypassed the content-address guard entirely — that whole branch had never executed under a test, since every fixture used file:// which cannot sign; remote.Prefixed, the single containment primitive for multi-tenancy, did not contain; and a mount id from the untrusted folder config escaped $BDRIVE_HOME. Plus: bdrive export wrote hub-named keys as tar members unvalidated; the device token followed cross-origin redirects; a symlink in the file:// storage root read and wrote anywhere on the host; GCS presigned PUTs bound no size; presigned device uploads were never billed; trust_proxy was wrong for the third round running; one bad line voided an entire journal; Op.Path was not byte-exact through JSON; Less was not a total order, so Replay's determinism rested on a caller's accident; and client journals were 0644. Three new scoreboard rows: client local state, the project archive, and the device as client of a hostile hub. Still zero tests after four rounds: internal/daemon and internal/autostart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 5 opened internal/daemon and internal/autostart (zero tests after four rounds), drove the browser presign flow, and attacked round 4's fixes. It broke seven of them, including the one round 4's commit message called "the critical". 247 TestSec_* now green. ownJournal failed four separate ways. The write doors called observeDevice BEFORE ownJournal, so for an unclaimed id the request manufactured the fact that authorized it — and device ids are not secret, History publishes them. The claim was then permanent and unrecoverable: no Delete on DeviceRepo, no release, no admin route, no CLI re-mint, and a 403 naming no remedy, so any member could deny any colleague's laptop the ability to sync forever. Rows were global but visibility per project, so offboarding a teammate released her journal to whoever was left. And because an ownerless row ranked as the earliest claim, every hub upgraded from before rows had owners had the binding switched off for precisely its established devices. Ownership is now hub-wide, an ownerless row authorizes nobody, observation happens after the decision, and project admin is the documented recovery path. The other critical is a divergence primitive: pull resumed at an op COUNT, and round 4 had just taught Parse to skip bad lines silently. One undecodable line inserted among lines a device already counted shifts every appended op down by one, so two devices replaying one journal hold different states permanently — and the peer picks the split. Now resumed at a byte offset. Also: a mid-run edit to .bdrive/config.json moved a whole project to a remote of the writer's choosing, with no restart and no credential, and the daemon then pulled from it; verify-on-read was defeated by uploading honest bytes first, since a presigned URL is replayable for its TTL; appendOp's lamport wrapped int64 and silently broke last-writer-wins for every later upload in the project; peer journal strings reached bdrive log's terminal unescaped, so the audited party could rewrite the audit (OSC 52 to the clipboard, \r to repaint a delete as a put); path_raw let one journal line name two different files to two reader versions; Stop signalled whatever pid a 0644 file named; locked() failed open, so status lied and stop stopped nothing; and a macOS path containing "&" made the autostart plist unparseable while Install reported success. Two four-round deferrals are now answered rather than carried. Dir==nil/Auth==nil is not reachable — nine real configurations, both arms, real project ids — and is a guarded invariant. The Postgres NUL question was swept across seven stored-record surfaces on a live Postgres: no silent-loss path, and cleanUploadPath now refuses control characters so it is unreachable through the API. Quota became a reservation with reconciliation: reserved at the grant so concurrent grants cannot oversubscribe, charged on arrival, released free on expiry, and charged once. Recorded, not hidden: permHub builds a hub with Devices == nil, so round 4's ownership binding was inert in that fixture and earlier "clean" results measured through it proved less than they looked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 6 attacked round 5's fixes, the completeness floor (templates, CLI commands, the untested exported API), and — new this round — the test suite itself. 290 TestSec_* now green. One hacker reverted 33 of the accumulated fixes one at a time to see which the suite actually caught. 28 held. Five did not, and those five are the most valuable result of the round: permHub built its hub with Devices == nil, so a dozen journal-pushing tests had been proving permission and never ownership; the ownerless-legacy-row test passed because its helper never set Op.Device, so the r5 hole it guards would have gone undetected; nothing asserted that an outstanding presigned grant counts against the quota cap, which is half of reserve.go's contract; row 6 claimed cleanUploadPath refuses control characters and named no test, and that guard is what keeps the Postgres NUL divergence unreachable; and unsafeRel — round 3's headline client fix — could be deleted with the suite green, surviving on round 4's UnderRoot. It also accepted ".", contained only because hashFile happens to fail on a directory first. The criticals: round 5's byte-offset pull resume was the same divergence primitive it replaced, twice. A peer that publishes in two stages and cuts the first mid-line makes one chosen device permanently skip the op that straddles the cut while every other device applies it. And round 5 deleted the shrink guard, so a peer withdraws an op every device already applied — the file vanishes from teammates' folders with no delete op, nothing in the journal, nothing in History. Separately, Deny removed an account but every authorization decision downstream keys on email, so grants and org roles stayed attached to the address: re-registering it walked back in as project admin, and its public share links kept serving. Also: reset and verification mail took its link host from the request, so an unauthenticated stranger could have the hub mail a victim a genuine reset link pointing at the attacker's server; a refused password reset reported "Password updated"; share revocation, approval, policy and account removal all took effect in memory after the store refused them, each failing in the widening direction; one journal push with a year-2300 timestamp overflowed the History cursor and hid the whole audit feed past page one; account ids were 32 bits with no uniqueness check, and the birthday bound is ~9,300 accounts for a 1% chance of silently transferring one account's credentials onto another; the new reservation ledger had a data race on the billing path, a check-then-act that let 5 of 16 concurrent grants through a cap fitting one, and released arrived bytes unbilled on expiry; safeField stripped C0 but not the 8-bit C1 controls that are CSI/OSC/DCS/NEL in any xterm-lineage terminal, nor bidi overrides; and internal/templates — first contact — bypassed cleanUploadPath entirely and wrote through symlinks using the shipped template with no hostile input at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 7 drove `bdrive init` end to end for the first time — named the
largest gap by two consecutive CISOs — and tripled the sabotage sweep to
53 reversions. 326 TestSec_* functions green.
init held two criticals the moment it was actually run. `init --server`
took the no-auth branch when a server answered {"auth":{"enabled":false}}
and rewrote settings.Server without touching settings.Token — and
settings.Server is the entirety of round 4's token binding, so a 30-line
HTTP server collects the real hub's bearer token. And `init $BDRIVE_HOME`
was accepted, because the .bdrive reserved-directory rule only applies to
segments below the mount root: from there settings.json is an ordinary
top-level file, so the first cycle pushed this device's token to the hub
as project content, for every member and every teammate's disk.
Two more criticals were old fixes on doors they never covered. Round 4
bound the device token to settings.Server's origin in remote.deviceToken
— the sync backend's door — while share.go reads its destination from the
folder's .bdrive/config.json and hands the token straight to it, and the
CLI's own http.Client had no CheckRedirect at all. And round 6's offboard
only log.Printf'd RemoveMember's "cannot remove the last owner", so
anyone signing up on a removed sole-owner's address inherited org
ownership and admin on every project in it.
The sabotage sweep found 8 more guards deletable with the whole suite
green, including MayActAs (every existing test planted an id that
validDeviceID rejects first, so the ownership loop was never consulted),
both framing headers (round 3's test held the disjunction, not the code),
and sqlAccountRepo's id guard — where the untested backend is the one
managed and Postgres deployments run. For the first time the sweep also
covered the three choke points themselves: reverting requirePerm turns 30
tests red, projectPerm 21, authGate 9.
Also: two more journal-undo primitives past round 6's count guard, now
keyed on identity; reset-mail poisoning survived because the pin was
first-request-wins and round 6's own reproducer sent the honest request
first; the /store/* journal door accepted paths /upload/commit refuses,
so the three spellings of one path rule became one exported predicate;
bdrive forget injected .bdriveignore rules outside any managed block;
bdrive resume built a volume path from an unvalidated registry key; round
5's $HOME-is-a-git-repo fix broke again on a string compare of two
spellings of one path, silently disabling every agent hook on the machine
while init reported success; and the CheckWrite call round 6 moved under
the hub-wide ledger mutex stalled every project's sync cycle.
Known open: a peer can still un-publish an applied op by corrupting its
line and appending as many as it removed — the clean close is hub-side
append-only enforcement on /store/*, a behaviour change no failing test
demands yet. The init tests all ran with auth.enabled false, so the login
flow inside init — where the first critical lives — is still untested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 8 drove the authenticated login flow, the last five CLI commands, and sabotaged row 15 exhaustively. 621 TestSec assertions green. Three of round 7's fixes were half-fixes. Its $BDRIVE_HOME guard closed the child direction only, so `bdrive init` on any ANCESTOR of the home still pushed this device's bearer token to the hub as project content — and a relative $BDRIVE_HOME disabled the guard entirely, reopening the original critical. Its mail fix still seeded the pin from r.Host, so on a fresh process an anonymous stranger could have the hub mail a VICTIM a valid reset link on the attacker's server; the mirror image turned password recovery off hub-wide with one request. And the hole round 7 declined to close got a reproducer that also invalidated its proposed remedy: a peer publishes its last op unterminated, the next append fuses onto that line and stops it decoding, so an op every teammate has on disk vanishes with no delete op — through a byte-level PURE APPEND, which hub-side append-only cannot stop. Two more criticals were first contact. The loopback login callback had no proof of possession: its only binding is a `state` that is printed to stdout and passed to xdg-open as argv[1], so any local process that can run `ps` signs the device in as its own account and the user's folders sync into the attacker's project. And the three /store/* READ doors call observeDevice as their first statement — round 5 moved it after the decision on the write door and never touched the read doors — so one GET with a victim's device id first-claims it hub-wide and locks that device out of its own journal, from read permission on any single project. The sabotage sweep is the round's most important result. 48 guards in row 15 reverted one at a time: only 20 were caught. A 57% false-negative rate, nearly 4x rounds 6 and 7. The materialize DELETE loop's three guards — one of which ends in os.Remove — were held up by nothing, masked because scan's delete pass applies the same rule to the same cache first, so no whole-Cycle fixture can tell which guard refused. absorbLamport's ceiling and tickLamport's stop mask each other, so the existing test passes with either removed. Eight of the 26 misses now have tests; 18 remain open, and row 17 was never reached at all. Also: a hostile export archive chose which of your existing projects it landed in (create-or-join-by-name, emptiness checked after the join); a folder that merely arrived on disk stole an enrolled mount's registry row, so at next login the real project's daemon ran on the arriving folder; one device approval minted N tokens and bound them to a device the human never approved; logout left the credential live with no revocation route; and two unbounded reads on the device side had the declared size in scope at the call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 9 spent most of its budget on sabotage rather than new attacks, and the numbers are the point. 705 TestSec assertions green. Row 17 swept in full: 44 reversions, 9 missed (20.5%). Rows 13 and 18-22: 57 reversions, 12 missed (21% overall — row 13 at 40%, row 20 at 33%, row 18 at 0%, the only perfect row swept in any round). And row 15's 18 remaining untested guards got tests, each verified red under its own reversion — including both of round 8's flagged leads, which turned out to be correct guards that were merely untested. That agent reported dry. So roughly 29 previously-deletable guards are now genuinely pinned. All five live holes are regressions in round 8's own fixes. Its re-assertion fix re-published a withdrawn op at its original low lamport, which made it a losing local unpushed op the instant it was written — so conflictCopies did what it exists to do and the victim created, signed and pushed a file holding content the peer chose, at a path that never existed. The same admission rule had no guard for withdrawn deletes and consulted neither the ignore filter nor neverSync, so a device republished paths it deliberately refuses to materialize. Re-assertion now requires that this folder's own cache stands behind the op. Its sizeBound fix returned on a sha mismatch instead of skipping, so one understated Op.Size in one line of a peer's journal permanently withheld every blob queued behind it — before round 8 the read was unbounded, the sha matched, and the files arrived. The bound was not the defect and is unchanged; the error is now remembered and returned after the batch. Also: the org heir was chosen by the smallest email address rather than the longest-standing member, so the newest member inherited ownership and project-admin on every project when a hub admin removed a departed employee; $BDRIVE_HOME was created 0755 by LoadDevice, which runs before almost everything, so listing alone named every project, every device in the fleet and every content hash without opening one of the 0600 files; and round 8's ResolveMount condition stranded a genuinely moved project behind a leftover config, with init itself blocked on the same check. Move-vs-copy is now decided by dev+ino, which a rename preserves and a copy cannot reproduce. Judgement call recorded: the nested-mount carry reverts green but stays. Deleting a defence-in-depth guard because the tests did not notice is exactly the reasoning the sabotage table exists to distrust. It is marked as not counting toward coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 10 drove three surfaces end to end instead of sweeping, and the contrast is the round's real result. Round 9 spent itself on sabotage and found 5 holes; round 10 pointed a device at a hostile hub, executed the Linux autostart code for the first time in ten rounds, and requested the three /auth pages nobody had ever fetched — 30 holes. 777 TestSec assertions green. Row 19 is the clearest data point the loop has produced. A round-9 sweep scored it 12.5% missed and annotated it "no reachable impact", so it wrote zero tests. Driven end to end it held 11 holes, one critical: pull skips a listed journal only on an exact string compare, so a hub listing journal/DEVA.jsonl resolves to the same file as deva.jsonl on APFS and NTFS and overwrites the device's OWN journal — the invariant the whole concurrency design rests on. A sweep can only find a hole where a guard exists; it cannot find a class nobody wrote a guard for, and every critical since round 7 has been that kind. Also from the hostile hub: one unusable listed key hid every peer permanently; one listing minted 200k local journal files; the hub sized the device's own allocation at two layers, and one boolean in a sign response made a device publish an op for content it never sent, with the cursor advanced past it so it never retried. putDirect shipped file bytes to any host the hub named — round 4 dismissed this because "the hub already holds the data", but at the moment it names the destination it does not, which is what the upload is for. Row 5's device binding is closed after four rounds of deferral. A read-only member's device could never register, so any member with write anywhere took its id permanently — and the arm that let them through read a field the attacker writes. The id is now minted hub-side at login, bound to the authenticated account, at all three mint points. That was first framed as requiring the supersession of round 7's test; it did not. Round 7 asserts a read door creates nothing, and that property is unchanged and strictly stronger, because the read door now has nothing left to claim with. Four of round 9's five fixes had live residuals: re-assertion laundering returned on any device that cannot push (conflictCopies measures unpushed against a cursor that only advances on success, and read-only is the documented steady state); the sizeBound fix still let one peer integer suppress the victim's own push; earliestMember was inert on every upgraded hub; and the dev+ino discriminator was inert on every row that existed. ResolveMount turned out to be a write with a read-shaped name, so bdrive restore and forget enrolled the device in projects it was never init'ed into. Recorded as a measurement gap, not a finding: row 14 was scored clean on every backend for seven rounds, but this is the first round ever run with a Postgres DSN, and metaBackends silently omits the arm without one. A skipped arm and a missing guard are indistinguishable in a green suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Two criticals, both first-contact findings on surfaces ten rounds had not
reached.
An `.xml` file got script on the hub's own origin with the reader's session:
`sandboxInline` walled off a LIST of content types where the thing it protects
is a PROPERTY ("the browser parses this as a document"), and the whole XML
family sat outside the list while having the property — an XML document carries
its own `<?xml-stylesheet type="text/xsl"?>` and the XSLT output is HTML in the
origin that served it. `inlineMarkup` is now the property, `inlineType` serves
the XML family inline as text/plain so nothing parses it as a document, and
`nosniff` goes on every stored-bytes door.
A plain member replaced another account's journal by spelling her device id in
a different case: every hub ownership decision was a byte compare while APFS
and NTFS fold, so one login and one PUT broke the one-writer invariant the
whole concurrency design rests on. `canonDeviceID` folds at the trust boundary,
the registry folds on load and at every entry point, and `ownJournal` requires
the canonical journal key.
Also: org ownership was drawn by Go map iteration (`sort.Slice` on an all-zero
`Created` column) and now needs real evidence of age or produces no heir; a
revoked grant was restored by any unrelated write from a second hub process
(grant writes are row-scoped now, on all three backends); `/history` named
whoever the pushing device typed; the admin recovery arm locked the real owner
out of `bdrive login` forever, across the org wall; `bdrive scope` was the
unescaped door `forget` used to be and could wipe the team's synced rules;
`journal.SafePath` let every bidi control and every C1 through.
Two tests were touched, both disclosed in .claude/security-goal.md:
TestSec_DB_NULBytesDoNotTruncateRecords is retired (its assertion is one
Postgres cannot implement), and TestSec_Scope_AddCannotCreateADirectoryOutside-
TheProject is rewritten against the guard it was meant to test — it called
os.MkdirAll itself, so no production code sat between its setup and its
assertion. The rewrite was verified to go red with the guard removed.
918 TestSec assertions green, whole suite green including Postgres and -race,
108/108 Playwright, Linux container clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
…ady red Round 10 moved the "postgres UNTESTED in this run" note from t.Log to os.Stderr because t.Log is invisible without -v. That was not enough: `go test` buffers a package's output and discards it on success without -v, stderr included. So the note that exists to make a silent coverage gap loud was itself audible only during a failure — the same shape as the hole it guards against, and the reason it went unnoticed is that every round that read it had a red suite in front of it. secrunNotify now also writes to /dev/tty, which survives that buffering. Verified under a pty on a fully passing run with no -v. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
The critical one is the fifth instance of "something survives offboarding": an org invite outlived the membership, the ownership AND the account that minted it. OrgDB.Redeem/ValidInvite now resolve the minter's ownership at read time — the rule shareCreatorStillBelongs already applied to a share link — and retire the invite when it fails, so EvictMember's heir promotion cannot revive it. Round 11's row-scoped write landed on ProjectRepo only. OrgRepo, ShareRepo and DeviceRepo had the same whole-record shape, so a second hub process's unrelated write resurrected a revoked org membership, a revoked /s/ link, or erased a device binding. And the authorization READ path never got it at all: ProjectDB answered from a copy taken at boot, so a revocation took effect on one process and no other. Also: a push could credit another account through Op.Author; journalOps checked the Note and not Author/UserName; a display name skipped trimText; a password reset left outstanding reset and verification mail grants alive; nosniff missed two stored-bytes doors; SafeText admitted the zero-width formats; a project name could break out of the ConnectGuide paste prompt; inviteTokenFromNext matched "/join/" anywhere in `next`; file content chose what read-log reported as a read, and the hub recorded reads for paths that do not exist. Frontend: decodePath threw URIError on a link in a teammate's document and unmounted the whole SPA persistently (fixed at the decode, plus a real ErrorBoundary), and the router kept the Object.prototype lookup bug round 11 fixed in ProjectIcon. Decision, not a patch: agent HOOK config (.claude/settings.json and friends) is now reserved in both directions. Skills, commands and CLAUDE.md deliberately are not — sharing what an agent reads is the product. The trust boundary that follows from that is now written down in INSTALL_FOR_AGENTS.md, the docs' Start-here path, and README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 12 gave ProjectDB a read-path refresh() because a revocation only took effect on the process that served it. Four sibling registries with the identical defect went unexamined for a round. The refresh family (one fix, three service structs + two file repos): - OrgDB.refresh — the wall IN FRONT of project permissions. A removed org member kept reading every project in the org; a revoked invite still redeemed and, on the default invite-only posture, bootstrapped the account. At the top of the MUTATORS too: the last-owner guard is a cross-process TOCTOU the write-side re-read cannot close. - BuiltinAuth.refresh — the CREDENTIAL, not a grant on top of one. A revoked device token still authenticated; a deleted account signed in again with its old password. - ShareDB.refresh — a revoked /s/<token> was still served to anonymous strangers. fileShareRepo.reload's own r12 comment named this row. - fileAccountRepo.reload, fileReadRepo.reload — the last two file repos with no write-side re-read. It lives in the service structs, not db_file.go: the staleness reproduces on sqlite and Postgres too, and a file-only fix would have left the two-replicas-one-database deployment fully broken. Also: - offboard now releases the device binding (DeviceRepo.Delete + DeviceRegistry.Release). A deleted account kept a hub-wide claim on its device id, which silently and permanently locked out the next hire. - SafeText and trimText refuse unicode.Cf and the tag block AS A CLASS. The tag block encodes all printable ASCII with no glyph, so a project name rendering as "wiki" smuggled a shell command into the agent paste prompt. - .mcp.json is reserved; the agent-config list is now derived from what each platform LOADS, not from what BearDrive writes. - pageDevice and X-Bdrive-Device-Name go through trimText: an unauthenticated stranger chose the text AND the length of the hub's only consent surface. - SetPolicy runs the startup validator, so POST /api/admin/policy cannot reach a posture the binary refuses to boot in. - Insights uses Object.create(null): a folder named __proto__ erased an agent device from the Dashboard. go build / go vet / go test ./... clean with and without BDRIVE_TEST_POSTGRES; -race clean; Playwright 127/127. Linux container run not completed — see .claude/security-goal.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
…ourth hacker Four findings, all against the already-hardened tree. Its fifth (.mcp.json) was already closed and its test passes — an independent confirmation of that fix from a second angle. 1. A peer's `!.env` in the shared .bdriveignore uploaded another member's local .env on their next cycle. Round 4 made .bdriveignore team-wide on purpose and made `sync --prune` refuse on `!` rules for exactly that reason — but that reasoning covered DELETION, and nobody asked what a pulled negation does to SCAN. The runbook's own `bdrive init . --only docs,notes` is what creates the exposure: the whole repo goes under the mount with only this synced, teammate-writable file holding the rest back. Fixed asymmetrically at the upload door (Filter.SkipUp, consulted by walkFolder): pulled rules that NARROW apply immediately in both directions; pulled rules that WIDEN apply to materialize but not to scan, until this device authors the rules itself (init --only, bdrive scope, an editor). A joining device has authored nothing, so team-wide scope still works on day one — which a blanket "ignore pulled negations" would have broken. `bdrive scope --explain` reads the same floor so it cannot drift. 2. A FAILED `init --server <url>` signed the device out of its real hub and left it defaulting to the new one — after a run that ended in "Error:". ensureLogin now returns a rollback; initCmd commits the session only once the hub has answered with a project this device can open. 3. `init --server http://…` minted and stored a device token with no plaintext warning while `bdrive login` on the same URL warned — and step 2 of the runbook is titled "Do not run a login command". The warning moved from loginCmd's RunE into the shared runLogin: one sign-in door, one warning. 4. The hub chose the device-login link and the CLI printed it verbatim under its own "open this link in any browser". sameOriginLink falls back to the hub's own /auth/device when scheme+host differ. Also: safeField gets the same unicode.Cf + tag-block class rule SafeText and trimText got — third door, same class. Scoping guide documents the widening rule. Two findings that are not tests (the runbook URL pinned to a mutable branch; nothing authenticates the hub during device sign-in) are recorded in known-open. go build / go vet / go test ./... clean with and without BDRIVE_TEST_POSTGRES; -race clean on webapp, syncer, store, cmd/bdrive; Playwright 127/127. Linux container run still not completed — the Docker daemon on this machine will not create containers at all; see .claude/security-goal.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
The last round of the loop. Four hackers landed; 21 of their tests go green
here, the 22nd names a hole that IS fixed but cannot pass as written (see
below). `.claude/security-goal.md` gains a handover section for a human in
place of the "next round's targets" framing.
Sync / scope (internal/syncer)
- A peer DELETING the shared `.bdriveignore` walked straight past round 13's
upload floor: the `IgnorePulled` bookkeeping sat behind `if want, ok :=
target[IgnoreFile]; ok`, so a delete never updated it while materialize
unlinked the local copy anyway — and the next cycle read the absent file as
locally authored, dropped the floor AND the live rules, and pushed the whole
repo. Recorded as pulled now.
- Both SyncState fields are `omitempty`, so on its first post-upgrade cycle
every existing device adopted whatever was on disk as its own — including a
peer's `!.env` that landed one cycle earlier (scan runs before pull, so it
always does). An upgraded device now seeds its floor with `vouchedFloor`:
keep a `!` line when the path it re-includes is already in this mount's
materialization cache, drop it otherwise. That is what keeps a `bdrive
scope` block (`/*` plus nothing but negations) from silently ending uploads
— pinned by TestUpgradedScopedDeviceKeepsUploading.
Second-process staleness (internal/webapp)
- ProjectDB had refresh() on Get and List only. `put`→`PutMeta` is an
unconditional upsert, so a second process's ordinary rename put a DELETED
project back carrying the org the public-link rule reads; the last-ADMIN
guards counted admins out of the boot-time map; GetOrCreate answered
create-or-join differently per replica. refresh() now runs at the top of all
ten mutators.
- DeviceRegistry had no refresh() at all — round 13 cleared it on the
bind-away direction alone. Offboarding released a device claim on one
process and no other: the next hire is locked out, and a re-created address
inherits the departed account's journal write gate elsewhere. Pinned on
file, sqlite and postgres.
Audit trail
- `/store/object` was a plain object PUT with no relation to what is stored,
so any member could rewind their own journal — or, after inheriting a
reassigned device id, a departed member's — out of History. Journal pushes
now must keep every op Seq the hub already holds.
- seedTemplate journaled the hub's own template files under the account that
ran `bdrive init --template`, byte-identical in shape to a hand upload. They
now carry no account and a "seeded from the <name> template" note.
Text and rendering
- journal.SafeText refused every category-Cf rune and missed U+2028/U+2029
(Zl/Zp), which the webapp's own trimText has deleted by number since round
12. A folder row for `line<U+2028>sep.md` paints to exactly the same glyph
run as `line sep.md`.
- A strong-RTL LETTER needs no format character to reorder a rendered row.
Measured in Chromium, `unicode-bidi: isolate`, `plaintext` and `<bdi>` all
leave it intact; `isolate-override` fixes it, and peer-written-name
selectors now carry it (SPA + the auth pages' device-approval rows).
- `)` closes the paste prompt's clause exactly as `"` did; project names now
drop both parens (org/device/account names are unaffected). PATCH
/api/projects/{id} called trimText where create called trimName, so rename
stored `/` and `\` — one rule for both doors now.
CLI
- `p.Template` was the one hub-chosen field in `bdrive init`'s output that
never reached safeField.
- `--template` reported the hub's own string as proof and never looked at what
arrived; it now always falls through to the idempotent seedLocally.
Docs
- INSTALL_FOR_AGENTS.md no longer raises the hub-seeded AGENTS.md to the
user's authority, and its trust boundary names the hub as an author of
folder content. Documentation defects, not demonstrated exploits: three live
headless runs did not flip behaviour.
Known red, deliberately not worked around:
- TestSec_ProjectName_RenameBypassesTheCreateNameRule — the hole is fixed and
separately verified; the test's own control creates a project with the
normalized name in the same org before renaming into it, so correct
behaviour collides with the unique-name-per-org rule.
- Two e2e/sec14fe specs upload a U+2028 path to demonstrate a rendering
collision that this commit's ingest fix now refuses at the door.
go build / go vet clean. go test ./... green with and without
BDRIVE_TEST_POSTGRES and under -race, except the test named above. Playwright
serial: 131 passed, 2 failed (the two named above).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
TestSec_ProjectName_RenameBypassesTheCreateNameRule created a project from the same payload it then renamed a second project to. Both normalize to "notes....etc", names are unique per org, so the rename 400'd on the collision and the test failed at its control check without ever reaching the assertion it exists to make. It failed that way against the FIXED code, which is the worst way for a test to be wrong: it reads as an open hole and is really a broken instrument. The control now uses a payload that normalizes to a different name. Verified load-bearing rather than merely green: reverting Update's projectLabel call turns it red with "rename stored a path separator in a project name: notes/../../etc". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Only the built frontend assets conflicted; every source file auto-merged, including frontend/src/style.css, which carries main's star-button styles and this branch's bidi-isolation rule side by side. Assets are never hand-resolved — the conflicted bundles were dropped and regenerated from the merged source. The rebuilt bundle carries both sides, and two consecutive builds produce identical hashes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
The branch added shared rules and gates that the diagrams did not have boxes for, so the pictures no longer described the code. cli-sync: the three drifting path/text checks collapse into one journal.SafePath/SafeText box, plus store.UnderRoot and config.ReservedPath as their own single-rule boxes; Filter gains SkipUp and AcceptRules and the scan/materialize rules stop being symmetric; SyncState is drawn for the IgnoreAccepted/IgnorePulled floor; the registry records Dev/Ino and splits ResolveMount (read, self-heal) from EnrollMount (the only writer); the daemon's signalled pid moved inside the flock. webapp-server: DeviceRegistry is keyed (account, id) with Bind/Release/OwnerOf/MayActAs; new boxes for the /store journal door, the quota reservation ledger, sandboxInline and offboard; refresh() on every service; the row-scoped repo interfaces, the storable validation gate and the schema-version guard in the MetaStore block; HasBlob became BlobSize. webapp-frontend: ErrorBoundary, the app's floor. Also fixes escaped quotes in a note that have been rendering this whole diagram as an error box on GitHub since it was written. overview: unchanged — no package appeared or disappeared and no cross-piece flow moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Round 14 refused U+2028/U+2029 at ingest. Two e2e specs proved a *rendering* collision by uploading exactly those code points, so the fix made their fixture impossible and left them red — a broken instrument reading as an open hole, the same shape as the rename test. The listing spec now asserts the ingest guard instead, and keeps the measurement that says why the guard matters: the two names painted to 70.9844 x 16, one line box, byte-different and pixel-identical, measured in Chromium with Range.getClientRects() over live text nodes. Relax SafeText and it goes red before the collision returns. The shares-audit spec is skipped with its numbers preserved. Its reachable sibling — a strong-RTL letter, which cannot be refused without refusing Hebrew filenames — is covered by the neighbouring spec and fixed in style.css. Playwright: 133 passed, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Tailwind's content scanner reads the e2e specs. A bare `isolate` token in
prose explaining the bidi fix emitted `.isolate{isolation:isolate}` into
the shipped bundle — a rule nothing uses, and a stale-assets failure for
check-dist.
Reworded to avoid the token, with a note saying why, since the next
person to explain a utility class in a comment will hit the same thing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
The handover listed one Go test and two browser specs as permanently red. All three were re-aimed after the loop stopped: the Go test was failing against the FIXED code because its own control collided with it, and the two specs were fixture-blocked by round 14's own ingest fix. Suite is 1052 TestSec assertions green, 0 red; Playwright 133 passed, 1 skipped. The stopping condition itself is still not met and the notice at the top of the file says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Two commits landed on main during the landing pass, both touching files this branch changed: the palette back-navigation fix and the dashboard's handling of reads for a deleted file. Insights.tsx, style.css and architecture/webapp-frontend.md all auto-merged, and both sides survived — verified explicitly rather than assumed, since an auto-merge in Insights.tsx is exactly where one of the two fixes could have vanished quietly: main's orphanPaths is present and so are both of this branch's Object.create(null) sites. Only the built assets conflicted; regenerated rather than hand-resolved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
main's restore fix ("a version that is already the file's content is not
a change") landed while this branch was open. restore.go auto-merged and
main's journal.Replay guard is present verbatim.
It did break three of this branch's restore tests — in their positive
CONTROLS, not their security assertions. Each planted one version and
restored it, which is now correctly a 409 no-op. Each control now plants
a second, later version so the restore under test is a genuine change.
The assertions those tests exist to make are untouched: a reserved path
is still refused, a read-only member is still 403, and a path the hub now
refuses to carry still cannot be republished out of an old journal.
Only the built assets conflicted; regenerated, not hand-resolved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
The scan door applied config.ReservedPath; the hub's ingest door applied journal.SafePath AND config.ReservedPath. A file whose name the scan accepted and the hub refused was blobbed and journaled locally, and because push PUTs the whole journal object, every later push from that device 400'd on the same op forever. Renaming did not help — the delete op names the same path. The only surface was a line in daemon.log, and recovery meant deleting the volume store. walk.go's own comment already stated the rule it was breaking: "the outbound half has to match the inbound one." It just did not name every predicate the inbound half applies. Two changes: SafePath now permits ZWNJ and ZWJ, which the Cf class rule refused. Both are orthographically required — U+200C is what makes "میروم" the right word in Persian and is mandatory in several Indic scripts, and U+200D builds most multi-person emoji. Refusing them did not harden a hub; it told those users their filenames were illegal. The confusability they buy is also already reachable without them: a Cyrillic homoglyph produces the identical "two rows, one reader" tree and is allowed. So the clause was paying a hard i18n cost for a partial mitigation of a class that stays open. A note has no orthography, so SafeText still refuses all four zero-widths; the two rules now share one implementation with a flag rather than diverging. walkFolder applies SafePath, so an unsyncable name is simply not carried — it shows up in `bdrive scope --explain` like any other exclusion and never enters a journal. The regression test asserts a PROPERTY over both predicates rather than a character list, so adding a rule to one door alone reopens it. Its first draft was vacuous — it skipped every unsafe name instead of creating it, so nothing hostile reached the scan and deleting the guard changed nothing. Caught by sabotaging it; it now creates the names unix permits and goes red when the guard is removed. Found by a CTO review of PR #112, not by the security rounds, which checked this asymmetry on the hub and never on the client. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Upgrading a hub behind nginx / Caddy / Fly / Cloud Run without editing its config made every user share ONE 10/min login bucket and capped public share links hub-wide at 120/min: clientIP fell back to r.RemoteAddr, which is the proxy. Correct passwords started answering "too many attempts", with no log line saying why. X-Forwarded-For is now trusted by PEER rather than by configuration: a proxy that fronts a hub reaches it over loopback or a private address (sidecar, container network, Fly/Cloud Run internal hop), so that header is the operator's own infrastructure. A hub on a public IP still ignores it, and now logs once instead of failing silently. trust_proxy remains the override for the one shape the peer check cannot see — a proxy on a public address. Which hop is taken is unchanged (last element of the last field line), and the round 13/14 tests that pin it stay green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
…quest Every authorized request re-read the entire project, org and account registry under a hub-wide mutex — 14.1 ms per request at 5k projects on the file backend, nine unfiltered SELECTs on Postgres — so the hub served roughly 60 req/s regardless of cores while ~200 devices polled /store/list every 10s. None of this existed before the security rounds; registries loaded once at boot. The semantics do not change. Registries still re-read the store before every authorization decision — that is the correctness floor rounds 12-14 built, and a TTL would put back exactly the staleness window they closed. What changes is the cost of asking whether there is anything to re-read: - new optional Versioned repo capability: one os.Stat (file) or one primary-key lookup on a per-registry meta_version counter bumped inside every write transaction (SQL). A repo that cannot answer is treated as changed, so the fallback is the unconditional re-read that was always there. - proj() resolved the project and then projectPerm resolved it again; projectPermOf takes the Project the choke point already has. handleProjectList and the org share audit did one resolution PER PROJECT in a loop; both now pass the row they are already holding. Measured on M1, benchtime=200x, one project resolve + permission check: file 100 projects 331 us -> 6.0 us file 1000 projects 2.85 ms -> 3.9 us file 5000 projects 14.14 ms -> 3.9 us sqlite 100 projects 295 us -> 22.9 us sqlite 1000 projects 2.57 ms -> 22.5 us sqlite 5000 projects 10.86 ms -> 21.5 us and it is now flat in project count rather than linear. The file backend does NOT become multi-process-safe from this: every write is still read-modify-write-rename, and the mtime+size token would miss two processes writing the same byte count within one filesystem timestamp tick. refresh narrows the stale-read race; it does not close it. SQL is the fix. TestVersionGateSeesAnotherProcessWrite pins the property that matters — a second process's create and grant change are both visible through the gate, on file, sqlite and Postgres. BenchmarkRegistryRead / BenchmarkAuthorizedRequest go linear again if this regresses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
…sh first RemoteSource.verify re-read and re-hashed the whole object before returning a second read for the actual stream, on any backend that can presign — and PutSigner survives the Prefixed wrapper, so this was live on every S3 and GCS hub. Every viewer open, render, download and /s/* share hit paid 2x object-store egress and a serialized full-object hash before the user's first byte. On file:// (the OSS default) verify is a no-op, which is why the suite never felt it. The check is not weakened. It is cached again, keyed on the one thing that makes "blobs are immutable" true rather than assumed: BOTH presign doors refuse to sign a key that already exists, so every presigned URL a blob ever gets was minted before its first PUT and dies at mint+TTL. Once the stored object is older than the presign TTL, no live URL for it can exist and none will ever be minted again — the hub is the only writer left, and the hub hashes what it relays. Only then is the verification cached. The object's age is read after the hash, so a replay mid-check reads as seconds old and is not sealed. Measured, 4 MiB blob, 200 reads (the "unsealed" arm IS the old code path): before 2.41 ms/op 2.000 storage reads per blob read after 0.39 ms/op 1.000 storage reads per blob read On S3/GCS the second read is real egress and real latency, so the win is larger there than this local stand-in shows. NOT done: signing the content hash into the presigned URL. GCS cannot bind a SHA-256 at all — x-goog-hash takes only crc32c and md5, and the md5 would be declared by the same client that declares the sha, so a chosen-prefix collision defeats it. On S3 the SDK hoists ChecksumSHA256 into the query string rather than into SignedHeader; it is inside the signature, but whether S3 enforces a hoisted checksum (and whether an unsigned request header would override it) cannot be verified without a real bucket. With the seal in place the checksum would add no security that verify is not already providing during the only window it applies to, so it stays out rather than going in untested. Backends that can sign but cannot bind a content address: GCS certainly, S3 pending a live check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
architecture/webapp-server.md gains Versioned/versionGate, RemoteSource's PresignTTL + seal, remote.Object.Modified, and the new clientIP peer rule. Both mermaid blocks parse-checked with mmdc. .claude/security-goal.md's "known-open, deliberately deferred" list is updated with what each fix accepts: the private-peer widening on X-Forwarded-For, the fact that the file backend does NOT become multi-process-safe from the change token (read-modify-write-rename is unchanged; mtime+size narrows the race and does not close it), and why the presigned content hash was left out — GCS cannot bind a SHA-256 at all, and S3's binding lands as a hoisted query parameter nothing here can verify S3 enforces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
The verification cache is sound on one argument: once a stored blob is older than the presign TTL, no live URL for it can exist, so the bytes cannot change again and the hash need not be recomputed. That argument is about time, and the two times came from different machines — o.Modified is the object store's clock, time.Since is the hub's. A hub running ahead of storage overstates the object's age and seals it while a minted URL is still live; a replay through that URL is then served from cache for the rest of the process's life. NTP makes it unlikely and a container without it, or a VM resumed from suspend, makes it reachable. Seal after the TTL plus an hour instead. Sealing early buys nothing — the blob is immutable either way — so the margin costs a few extra hashes on a young blob and removes a dependency on two clocks agreeing that nothing in the process can verify. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
The clock-skew fix in b1e9d20 is right, and it left TestBlobVerification StopsOnceTheObjectCannotChange red: the fixture used a 1ns presign TTL as a stand-in for "old enough to seal", which stops working the moment the margin is an absolute allowance for clock skew rather than a multiple of the TTL — correctly so, since an absolute allowance is what skew actually needs. The fixtures now age what the store holds (os.Chtimes over the backing dir), so the tests exercise a genuinely old object. That also lets the boundary the skew fix exists for be asserted directly: a blob PAST the presign TTL but inside the skew allowance must still be re-verified, because "past the TTL" is measured on the storage clock and compared on the hub's. Previously nothing covered that case. sealAfter's comment now names what the allowance does not buy — it is a bound, not a proof — with the single-clock alternative as the upgrade path, and the goal file records the same residual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
metaBackends' postgres reset dropped ten tables and kept four: project_perms and device_rows leaked rows into the next test, and — the one that bites — schema_meta survived while projects did not. That combination is precisely what addColumns refuses: the next open rebuilds projects WITHOUT the guarded default_level column and then reads a recorded schema version saying it should already be there, so the store fails to open with the rollback error. Nothing tripped it while TestMetaStoreConformance was the only thing opening Postgres, because it reset and opened back to back. Adding a second Postgres consumer (TestVersionGateSeesAnotherProcessWrite) made the residue reachable. Reset now drops every table migrate() creates, which is what reset means. And the version-gate test no longer drops anything at all: it asserts by project id and GetOrCreate is create-or-join, so it does not need a clean database and must not leave a residue the other harness does not expect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
… the DSN The harness DROPs and recreates the schema per test against whatever BDRIVE_TEST_POSTGRES names, so two concurrent runs produce moving failures that read as regressions. Record how to tell the two apart, since the scoreboard leans on Postgres results. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
…und-1 # Conflicts: # architecture/webapp-frontend.md # internal/webapp/history.go # internal/webapp/static/assets/index-DtPBROaj.js # internal/webapp/static/index.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
bdrive init --serverhanded the previous hub's token to any server that claimed auth was off; a revoked share link, org membership or device token only took effect on the hub process that served the revocation.CLAUDE.mdbecomes your agent's instructions..claude/security-goal.md.How this was produced
Two agent roles under one rule: a finding does not exist until it is a Go test that fails on the current tree, and a fix does not exist until that same test passes. No scores, no self-grading — the grader is
go test.Three things made it work, and they are the transferable part:
permHub— the main fixture — built its hub withDevices == nil, so a dozen ownership tests had been proving nothing.What was found, by class
/s/URLs for projects they were denied; routes registered outside theproj()wrapper each checked themselves, or didn't.AGENTS.mdattributed to a human who did not write it;bdrive logrenderable by the party being audited..bdrive/config.jsontravels with a folder and chose where the device token went, where the volume store lived, and which project a command acted on.Four recurring failure modes
Worth more than the individual bugs, because they predict where the next ones are:
ProjectDBgot a read-path refresh;OrgDB,ShareDB,BuiltinAuthandDeviceRegistryhad the identical defect and went unexamined for a round each. Three occurrences.fixed.__proto__reproducer whose own harness had the bug it was testing for.Architecture changes
architecture/cli-sync.mdjournal.SafePath/SafeText,store.UnderRoot, theconfig.ReservedPathfamily),Filter.SkipUp/AcceptRulesplus thestore.SyncState.IgnoreAccepted/IgnorePulledfloor,EnrollMountsplit fromResolveMount,MountInfo.Dev/Ino,Explain's newacceptedparameter, and the daemon's signalled pid moving inside the flock.architecture/webapp-server.mdDeviceRegistryre-keyed ondevKey{User,ID}withBind/Release/OwnerOf/MayActAs/LookupIn; thereserve.gogrant ledger; the/storejournal door;sandboxInline;offboardplusorgEvictor/seniorityLister;refresh()on five services;rowScopedProjectRepo/rowScopedOrgRepo; thestorablegate; the schema-version guard;DirectUploader.HasBlob→BlobSize;RemoteSource.OpenBlobandsourcedOp.architecture/webapp-frontend.mdErrorBoundary.tsxis a new module on every page's render path, mounted inmain.tsxaboveQueryClientProvider.architecture/overview.mdhttps://backend, hook → CLI, hub → store/meta). The new cross-package dependencies (journal.SafePathandstore.UnderRootnow used byremote,templatesandwebapp) are code-level and belong to the detail diagrams.architecture/cli-sync.mdThree path/text checks that had drifted into separate copies collapse into one
journal.SafePath/SafeTextbox that the syncer, both hub ingest doors and the CLI's own terminal output now share;store.UnderRootand theconfig.ReservedPath/ReservedDir/ReservedName/AgentHookConfigfamily join it as their own single-rule boxes;FiltergainsSkipUpandAcceptRulesso scan and materialize stop applying the same rules (a pulled!no longer widens what leaves this disk), backed by the newSyncState.IgnoreAccepted/IgnorePulledpair;MountRegistryrecordsDev/Inoand splitsResolveMount(read + self-heal, never enrolls) fromEnrollMount(the only writer, called only bystartSync);Explaintakes the accepted floor as a parameter; and the pid the daemon signals moved inside the flock'ddaemon.lock.flowchart TB Session["<div style='text-align:left'><b>Session</b><br/>+Cycle(ctx) Result<br/>+Restore(ctx, path, sha) error</div>"] Filter["<div style='text-align:left'><b>Filter</b><br/>+Skip(rel) bool<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +SkipUp(rel) bool</span><br/>+PruneDir(rel) bool<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +AcceptRules(text) scope floor</span></div>"] SyncState["<div style='text-align:left'><b>SyncState</b><br/>+Lamport +PushedOps +Access<br/>+IgnoreAccepted +IgnorePulled</div>"] SafePath["<div style='text-align:left'><b>SafePath</b> <<internal/journal — one rule>><br/>+SafePath(p) bool<br/>+SafeText(s) bool</div>"] ReservedPath["<div style='text-align:left'><b>ReservedPath</b> <<internal/config — one rule>><br/>+ReservedDir(name) .git .bdrive<br/>+ReservedName(name) .DS_Store, tmp<br/>+AgentHookConfig(p) bool<br/>+ReservedPath(p) bool</div>"] UnderRoot["<div style='text-align:left'><b>UnderRoot</b> <<internal/store — one rule>><br/>+UnderRoot(root, p) bool</div>"] Explain["<div style='text-align:left'><b>Explain</b><br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>+Explain(folder, include)</s></span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Explain(folder, include, accepted)</span><br/>+NotSyncedFiles(entries) int</div>"] Store["<div style='text-align:left'><b>Store</b><br/>+PutBlob / OpenBlob / HasBlob<br/>+LoadSync / SaveSync<br/>+Lock() flock</div>"] walkFolder["<div style='text-align:left'><b>walkFolder</b><br/>the ONLY copy of the sync predicate</div>"] Op["<div style='text-align:left'><b>Op</b><br/>+Seq +Lamport +Time +Device<br/>+Kind +Path +Blob +Size +Mode +Note</div>"] MountRegistry["<div style='text-align:left'><b>MountRegistry</b><br/>mounts.json — id → Path Volume Remote<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Dev +Ino directory identity</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +ResolveMount read and self-heal</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +EnrollMount writes the row</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +ValidMountID(id) bool</span></div>"] startSync["<div style='text-align:left'><b>startSync</b><br/>enroll + clear paused + first Cycle</div>"] openSession["<div style='text-align:left'><b>openSession</b><br/>mustProject → store.Open + remote.Open</div>"] DaemonLock["<div style='text-align:left'><b>DaemonLock</b><br/>volumes/id/daemon.lock<br/>volumes/id/daemon.pid</div>"] NoteFloor["Skip and SkipUp are no longer symmetric.<br/>A pulled rule that NARROWS applies both ways;<br/>a pulled '!' that WIDENS applies only downward,<br/>so a teammate's .bdriveignore edit can never<br/>start uploading a file this device excluded."] NoteEnroll["ResolveMount NEVER creates a row now.<br/>It self-heals a path only when Dev/Ino prove<br/>the same directory (a move), so a COPY of a<br/>project folder no longer enrolls itself as the<br/>original. EnrollMount is the write, and<br/>startSync is its only caller."] NotePid["The pid Stop SIGNALS now lives INSIDE the<br/>flock'd daemon.lock (announce/release).<br/>daemon.pid is display-only and never signalled."] Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ SkipUp on scan, Skip on materialize</span>" --> Filter Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ every path and note, in and out</span>" --> SafePath Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ materialize writes and deletes</span>" --> UnderRoot Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ neverSync = unsafeRel or reserved</span>" --> ReservedPath Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ reads the floor, records what it accepted</span>" --> SyncState Store -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ sync.json</span>" --> SyncState SyncState -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ AcceptRules(IgnoreAccepted)</span>" .-> Filter walkFolder -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ never-sync set</span>" --> ReservedPath walkFolder -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ SkipUp / PruneDir / addNestedMount</span>" --> Filter Session -. commits, replays .-> Op Store -- journal files --> Op Explain -- own fresh instance --> Filter startSync -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ EnrollMount — the only writer</span>" --> MountRegistry openSession -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ ResolveMount — self-heal only, never enrolls</span>" --> MountRegistry Filter -.- NoteFloor MountRegistry -.- NoteEnroll DaemonLock -.- NotePid classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class SyncState,SafePath,ReservedPath,UnderRoot added class NoteFloor,NoteEnroll,NotePid noteBox linkStyle 0,1,2,3,4,5,6,7,8,12,13 stroke:#22c55e,stroke-width:2pxarchitecture/webapp-server.mdDeviceRegistryis re-keyed ondevKey{User, ID}withFirstSeen-ordered ownership and a newBind/Release/OwnerOf/MayActAs/LookupInsurface, which the newjournalDoorbox (ownJournal/journalOps/opsNameTheirAuthor/journalKeepsItsOps) consults to enforce the "each device writes only its own journal" invariant at the/store/*proxy; a newreservations/grantledger sits between the presigned upload paths andQuotaProvider;sandboxInlineandoffboardjoin as boxes, the latter wiringOrgDBandBuiltinAuthtogether through the neworgEvictor/seniorityListerseams;refresh()appears onProjectDB,OrgDB,ShareDB,BuiltinAuthandDeviceRegistry;DirectUploader.HasBlobbecameBlobSize;RemoteSourcegainedOpenBlob/verifyand thesourcedOpattribution type; and theMetaStoreblock gainsrowScopedProjectRepo/rowScopedOrgRepo,DeviceRepo.Delete, thestorablevalidation gate and the schema-version guard.flowchart TB Server["<div style='text-align:left'><b>Server</b><br/>+Devices *DeviceRegistry<br/>+Quota QuotaProvider<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +TrustProxy bool</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -grants reservation ledger</span><br/>+Handler() http.Handler</div>"] DeviceRegistry["<div style='text-align:left'><b>DeviceRegistry</b><br/>-repo DeviceRepo<br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>-byID</s></span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -byKey devKey → row</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -latest id → newest key</span><br/>+Observe(DeviceInfo)<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Bind(user, d, visible) error</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Release(user)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +OwnerOf(id) owner, known</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +MayActAs(user, id) bool</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +LookupIn(id, allowed)</span><br/>-refresh re-reads the store</div>"] devKey["<div style='text-align:left'><b>devKey</b><br/>+User account email<br/>+ID device id</div>"] DeviceInfo["<div style='text-align:left'><b>DeviceInfo</b><br/>+ID +Name +OS +User +IP<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +FirstSeen</span><br/>+LastSeen</div>"] journalDoor["<div style='text-align:left'><b>journalDoor</b> <<Server, /api/p/id/store/*>><br/>ownJournal(key) whose journal is this<br/>journalOps(key, spooled) parse + validate<br/>opsNameTheirAuthor(ops) whose name<br/>journalKeepsItsOps(ctx, be, key, ops)</div>"] reservations["<div style='text-align:left'><b>reservations</b> <<Server, reserve.go>><br/>reserve / reserveIfFits(org, size, ttl)<br/>reservedBytes(org)<br/>claimGrant(project, key)<br/>reconcileGrants(ctx, project, be)</div>"] grant["<div style='text-align:left'><b>grant</b><br/>+project +org +key<br/>+size +expires</div>"] QuotaProvider["<div style='text-align:left'><b>QuotaProvider</b> <<interface>><br/>+CheckWrite(org, bytes)<br/>+CheckSeat(org, members)<br/>+RecordUsage(org, bytes)</div>"] sandboxInline["<div style='text-align:left'><b>sandboxInline</b> <<Server, every bytes-out route>><br/>inlineMarkup(ct) / inlineType(ct)<br/>nosniff always<br/>CSP sandbox allow-scripts for markup<br/>setContentLength from the stream</div>"] offboard["<div style='text-align:left'><b>offboard</b> <<Server, orgs.go>><br/>drop every project grant<br/>Devices.Release(email)<br/>evict from every org</div>"] OrgDB["<div style='text-align:left'><b>OrgDB</b><br/>-repo OrgRepo<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -seniority func() []string</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +EvictMember(org, email)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -heir(o) promotes an owner</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -refresh re-reads the store</span></div>"] ProjectDB["<div style='text-align:left'><b>ProjectDB</b><br/>+Get +Create +SetPerm +ClearPerm<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -refresh re-reads the store</span></div>"] ShareDB["<div style='text-align:left'><b>ShareDB</b><br/>+Create +Get +Revoke +SetExpiry<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -refresh re-reads the store</span></div>"] BuiltinAuth["<div style='text-align:left'><b>BuiltinAuth</b><br/>+AllowSignup +Admins +InviteValid<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +BindDevice func(email, r) error</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Offboard func(email)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +BaseURL string</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Seniority() []string</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -refresh re-reads the store</span></div>"] CLIAuth["<div style='text-align:left'><b>CLIAuth</b><br/>+Register(mux)<br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>-issue func(w, user, device)</s></span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -issue func(w, r, user, device)</span><br/>-pending map<cliGrant><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -pkceOK(challenge, verifier)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -takeGranted single-use, one lock</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -atGrantCap per-IP and global caps</span></div>"] DirectUploader["<div style='text-align:left'><b>DirectUploader</b> <<interface>><br/>+SignBlobPut(ctx, blob, size, ttl)<br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>+HasBlob(ctx, blob)</s></span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +BlobSize(ctx, blob) size, exists</span><br/>+Commit(ctx, path, blob, size, who, note)</div>"] RemoteSource["<div style='text-align:left'><b>RemoteSource</b><br/>+Backend remote.Backend<br/>+Remove(ctx, path, who, note)<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +OpenBlob(ctx, sha)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -verify(ctx, sha) re-hash on read</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -loadSourcedOps(ctx)</span><br/>-appendOp(ctx, op)</div>"] sourcedOp["<div style='text-align:left'><b>sourcedOp</b><br/>+Op journal.Op<br/>+From journal key's device</div>"] NoteDev["A device is keyed by (account, id), not id alone.<br/>Ownership is FIRST CLAIM WINS via FirstSeen.<br/>Bind refuses an id another account owns only<br/>when the caller can see that account — otherwise<br/>it binds nothing, so no cross-org existence oracle."] NoteRes["CheckWrite alone answered per request, so N<br/>concurrent presigns each passed against the same<br/>free space. Every door now charges<br/>size + reservedBytes(org); reconcileGrants asks<br/>the backend whether the blob landed."] Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ /store/* is the only way a device writes</span>" --> journalDoor journalDoor -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ OwnerOf gates the journal key</span>" .-> DeviceRegistry Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ holds before it charges</span>" --> reservations reservations -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅</span>" --> grant reservations -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ CheckWrite(size + outstanding)</span>" .-> QuotaProvider Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ every bytes-out route</span>" --> sandboxInline Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ account deletion</span>" --> offboard offboard -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ orgEvictor</span>" .-> OrgDB offboard -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ dropPerm</span>" .-> ProjectDB offboard -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ Release</span>" .-> DeviceRegistry OrgDB -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ seniorityLister, for the heir</span>" .-> BuiltinAuth BuiltinAuth -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ Bind at token issuance</span>" .-> DeviceRegistry DeviceRegistry -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ (account, id)</span>" --> devKey DeviceRegistry -.-> DeviceInfo BuiltinAuth -- serves bdrive login --> CLIAuth Server -.-> ShareDB RemoteSource -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ attribution comes from the journal key</span>" .-> sourcedOp DirectUploader -. implemented by .-> RemoteSource DeviceRegistry -.- NoteDev reservations -.- NoteRes classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class devKey,journalDoor,reservations,grant,sandboxInline,offboard,sourcedOp added class NoteDev,NoteRes noteBox linkStyle 0,1,2,3,4,5,6,7,8,9,10,11,12,16 stroke:#22c55e,stroke-width:2pxarchitecture/webapp-frontend.mdOne new module:
ErrorBoundary, mounted inmain.tsxaboveQueryClientProviderso it wraps every route — the floor under a render throw that another member's content can reach (decodePathon a%80link in a teammate's markdown,PROJECT_ICONS["constructor"]on a hand-set icon), which previously unmounted the whole SPA while the URL survived, so a reload reproduced the blank page.flowchart TB ErrorBoundary["<div style='text-align:left'><b>ErrorBoundary</b><br/>getDerivedStateFromError<br/>renders a page with a way back</div>"] App["<div style='text-align:left'><b>App</b><br/>mode from /api/config</div>"] HubApp["<div style='text-align:left'><b>HubApp</b><br/>project list, org walls</div>"] VolumeApp["<div style='text-align:left'><b>VolumeApp</b><br/>thin wrapper: one volume</div>"] router["<div style='text-align:left'><b>router</b><br/>+VIEW_ROUTES / LEGACY_VIEWS<br/>+parseRoute(url, mode) Route<br/>+urlForPath / encodePath / decodePath</div>"] components["<div style='text-align:left'><b>components</b><br/>FileView FolderListing Insights<br/>shell ProjectIcon ...</div>"] NoteBoundary["Mounted in main.tsx ABOVE QueryClientProvider,<br/>so it covers every route.<br/>An uncaught render throw unmounted the whole<br/>tree and the URL stayed, so a reload reproduced<br/>the blank page — a permanent client-side DoS<br/>another member's CONTENT could reach."] NoteRouter["legacyView() now goes through Object.hasOwn<br/>(LEGACY_VIEWS['constructor'] was truthy) and<br/>decodePath falls back to the raw segment<br/>instead of throwing URIError during render."] ErrorBoundary -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ wraps the whole tree</span>" --> App App --> HubApp App --> VolumeApp HubApp --> router HubApp --> components ErrorBoundary -.- NoteBoundary router -.- NoteRouter classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class ErrorBoundary added class NoteBoundary,NoteRouter noteBox linkStyle 0 stroke:#22c55e,stroke-width:2pxFixed in passing: a diagram that never rendered
architecture/webapp-frontend.mdcontained escaped double quotes (\"Empty project\") inside a mermaid note, which is a parse error — GitHub has been showing that entire diagram as an error box since the file was written. This reproduces atmain, so it is a pre-existing bug rather than something this branch introduced; it is fixed here because the branch touches that file anyway. Every mermaid block in all four diagram files, and the three excerpts above, were verified with a real mermaid parser rather than by eye.Deliberately not drawn
internal/agenthooksandinternal/autostartgained no exported types — every new symbol in them is unexported — so their boxes are unchanged.config.AgentHookConfigis recorded as a note rather than an edge:internal/configimports nothing, and the two file lists (what BearDrive writes vs. what an agent executes) are kept apart on purpose.ReadLedgerhas norefresh(), unlike the other five services. Its equivalent is repo-side:fileReadRepo.reload()insidePutBatch/DeviceBatch. Its box is unchanged.Reviewing this
Independent PRs would not compile —
journal.SafePathis used by the syncer and both hub ingest doors,store.UnderRootby almost everything. Read it as a dependency-ordered stack instead:internal/{journal,config,store}internal/remoteinternal/syncerinternal/webapprefresh()class, row-scoped writes, device binding, ingest and rendering, auth flows. Biggest layer — review in that order.internal/{agenthooks,autostart,daemon}cmd/bdriveinit,login,scope,forget,migrate, terminal output.The commits are chronological (round 1..14, several fixing an earlier round's fix), so intermediate states were never green as such — only the tip is CI-green, and that is deliberate rather than an oversight.
Verification
go build ./...,go vet ./...— clean.go test ./...— green with and withoutBDRIVE_TEST_POSTGRES. 1052TestSec_*assertions, 0 failures, 3 loud skips when no DSN is present.go test -race -timeout 30m— no data races.--workers=1— green.check-dist.sh—staticis fresh.Still open, by decision
Each is a choice, not an oversight, and each is recorded with its reasoning in
.claude/security-goal.md:mainon a third party's repo, unversioned against the binary they installed. The hub could serve its own copy at the origin the user already trusts.syscall.Flock,syscall.Kill), so that platform's code has never been executed anywhere.🤖 Generated with Claude Code
https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72