feat(env-bridge): the machine verifies the owner's click itself — a passkey assertion bound to the frozen request (hardening B) - #2599
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 57091d7fbe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| approvalIntent: { challengeId, scope, expiresAt: pending.expiresAt }, | ||
| // The assertion rides INSIDE the intent, so the grant signature covers | ||
| // it — relayed intact, never verified-and-discarded here. | ||
| approvalIntent: { challengeId, scope, expiresAt: pending.expiresAt, ...(parsed.data.assertion !== undefined && { assertion: parsed.data.assertion }) }, |
There was a problem hiding this comment.
Carry the assertion into the signed grant
In the actual route → bridge-client → signer path, this assertion is dropped: signGrantFrame reconstructs approvalIntent in apps/web/src/lib/env-bridge/grant-signer.ts with only challengeId, scope, and expiresAt. Consequently every browser approval reaches the daemon without an assertion, verifyOwnerApproval returns approval_unproven, and the route has already consumed the pending challenge. Preserve the assertion when constructing the signed grant and test the full path through the signer.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed, and this was the important one — the feature did not work at all. Fixed in 822015b92.
signGrantFrame rebuilt approvalIntent from {challengeId, scope, expiresAt}, so every real browser approval reached the machine with no assertion, was refused approval_unproven, and the route had already spent the pending question — so the owner could not even retry. Your reading of why the tests missed it is exactly right: the route, the daemon and the codec each had coverage, and none crossed the hop between them.
So the fix is one line and the test is the point. apps/web/src/app/api/env-bridge/approvals/__tests__/click-end-to-end.test.ts drives the whole path with nothing mocked between the hops — the card's POST body → the real route → the real signGrantFrame under a real Ed25519 keyring → the real canonical bytes, JSON round-tripped as a socket sends them → decodeFrame → verifyGrant → verifyOwnerApproval — and asserts the assertion survives each one and the daemon allows the run. The last three are the daemon's own gates; only the socket is stubbed. It also covers stripping or altering the assertion after signing (bad_signature, since it is under the server's signature) and a click the owner never signed (still approval_unproven — the route cannot invent a proof).
Mutation: reintroducing the exact defect turns that row red; the identity control survived.
Separately, and prompted by your last paragraph: the route no longer burns the question on a refusal that is not the owner's decision (1b4e77ebe). approval_unproven and a transport failure now restore it — the daemon had not spent its challenge either, so the two sides were disagreeing until the TTL — while allow, deny and approval_mismatch still spend it.
| domain: OWNER_APPROVAL_SIGNING_DOMAIN, | ||
| envId: binding.envId, | ||
| challengeId: binding.challengeId, | ||
| requestHash: ownerApprovalRequestHash(binding.request, sha256), | ||
| }), |
There was a problem hiding this comment.
Bind the requested approval scope to the assertion
In the compromised-server/signer scenario this hardening targets, the WebAuthn assertion authenticates the environment, challenge ID, and request hash, but not the owner's selected scope. A server that receives an assertion for a once approval can relay the first accepted grant with scope: 'until_revoked'; the daemon will verify the same assertion and persist a durable approval. Include the selected scope in the challenge binding and have the daemon recompute it from the received intent.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 405c1c985. This was the sharper of the two: it is the attack this workstream exists to stop, turning one click into a standing capability.
scope is now inside the derivation — {domain, envId, challengeId, requestHash, scope} — and the daemon recomputes the challenge from the scope in the received intent, so a proof made for once and relayed as until_revoked derives a different challenge and is refused approval_unproven:challenge_mismatch with nothing run and nothing remembered.
The one design consequence: the owner picks the scope on the card, after the GET. So the route now derives one challenge per scope and the card signs the one matching its selection; a card with no challenge for the selected scope refuses to sign rather than signing the wrong one. All four are distinct, so a proof authorises exactly the scope it was made for.
Rows added: each scope pair derives a different challenge; all four distinct; the relay refused at the verifier with the honest scope still allowed; the relay refused at the dispatcher asserting writes stays empty (nothing durable may come of a relayed proof); the relay refused end to end through the real signer; and two card rows.
Mutation, as you asked: dropping scope from the derivation turns the relay rows red. So does making the daemon recompute from the frozen scope instead of the received one — which would have looked correct and defeated the whole check.
| } | ||
| // Trailing bytes mean this was not a bare COSE key; refuse rather than guess. | ||
| if (cursor.offset !== cose.length) return null; | ||
| if (kty !== COSE_KTY_EC2 || alg !== COSE_ALG_ES256 || crv !== COSE_CRV_P256) return null; |
There was a problem hiding this comment.
Accept passkey algorithms issued by registration
Both passkey registration flows leave supportedAlgorithmIDs at SimpleWebAuthn's multi-algorithm default, so existing accounts can hold EdDSA or RSA credentials, but this new verifier accepts only EC2/ES256/P-256. If such a credential is pinned—or selected from a mixed credential set—the browser ceremony succeeds and the daemon rejects it as bad_credential, consuming the pending approval. Either restrict registration and pinning to ES256 or verify every algorithm the application can register.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in a9f9b4ae0. I took the option you preferred — verify every algorithm the app can register — because restricting would break credentials people already hold, and the failure mode you describe is the worst possible shape: a ceremony that succeeds and a machine that always refuses, with the question consumed.
Verified against the installed package rather than assumed: defaultSupportedAlgorithmIDs is [-8, -7, -257], and neither registration flow overrides it. All three are native to node:crypto (ES256 = ECDSA/SHA-256 over DER, EdDSA = Ed25519 over the raw message, RS256 = RSASSA-PKCS1-v1_5/SHA-256), so this still costs no dependency.
coseEc2ToJwk becomes coseToJwk and reads the labels by kty, never positionally — your note about the COSE shapes is why: -1/-2 are crv/x for EC2 and OKP but n/e for RSA, and a parser that assumed one shape would silently misread another. A kty/alg pair that does not match is refused, never coerced.
Tests use real Ed25519 and real 2048-bit RSA credentials end to end (each with an impostor row under the same pinned credential id), and there is a drift guard asserting the accepted set equals SimpleWebAuthn's own default — so if registration ever narrows or widens, that row fails rather than a credential going quietly unverifiable. The three per-algorithm branch guards each got an isolating row and all three are mutation-killed.
Measured cost on the wire, since RS256 is the big one: the assertion payload is 696 B and a whole grant_exec frame carrying it is 1 548 B, against a 1 048 576 B frame limit.
57091d7 to
2aea0fe
Compare
…st, and the total verifier for it (B2)
The WebAuthn challenge a Tier B approval is signed over is DERIVED, never
random:
challenge = SHA256({ domain, envId, challengeId, requestHash })
requestHash = SHA256(canonicalizeArgs(frozen request))
so an assertion cannot be moved to another request, another pending
question, another environment, or another message type. Both sides derive
it independently — the server from the frozen request the machine SIGNED
into its `grant_denied`, the daemon from the request it actually froze —
and it is the daemon's derivation that decides.
Also lands the pure verifier the daemon will run (wired in B4): COSE EC2 →
JWK, then the fixed check order no_pinned_credential → malformed →
wrong_type → challenge_mismatch → origin_mismatch → rp_mismatch →
user_not_present → unknown_credential → bad_credential → bad_signature.
ES256 is P-256 with an ASN.1 DER signature, which node:crypto verifies
natively, so the SHA-256 and verification primitives are injected and this
module stays pure — no new dependency anywhere.
Nothing in the repo parsed CBOR, so `coseEc2ToJwk` reads exactly the subset
a COSE_Key can legally be and refuses everything else with `null`: total by
construction, so a malformed pinned key or hostile bytes are a refusal, not
an exception.
`pendingRequestForWire` moves here from the dispatcher (B4 rewires it): it
is now load-bearing twice — what the `ask_pending` frame carries AND what
the challenge derivation hashes — so the two cannot drift.
Tests: 52 rows, red first, from leaf pages zwqrwnun4g2cgx3xu995k4by and
ihf41c2amxdob7a7vdg0t00c. Mutation: 17/19 killed by line index (control
mutant correctly survived); the two survivors are the indefinite-length and
byte-string-bounds guards, named in the source as provably subsumed by the
trailing-byte check, which is killed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…t (B1)
Trust on first use, at the one moment the owner is provably at the keyboard
— the same shape the server signing key already uses, in the other
direction. PageSpace has had passkeys all along; what was missing is that
`passkeys.publicKey` never reached any client, so the machine had no way to
recognise its owner and had to take the server's word.
Server: `POST /api/env-bridge/enroll` answers `ownerCredentials
{ rpId, origin, credentials[] }`, and the same update that pins the machine
key writes them to a new `drive_env_local.ownerCredentials` jsonb column.
`pinMachineKey` is the ONLY writer of that column — a test reads the store
source and pins that — so no server-mintable path can add a credential
later (leaf B5). `listPasskeyPublicKeys` is the one place a COSE public key
leaves the server, and its docblock says why that is safe and why the bridge
needs it.
If the passkey store cannot be READ the enrolment refuses
(`owner_credentials_unavailable`, 503) without spending the code: pinning an
empty set on a database hiccup would leave an owner who does have a passkey
permanently unable to approve in chat, with nothing to explain it. An owner
who genuinely has none pins an EMPTY set — distinguishable from "never
pinned" — and is told at the terminal, in one line, that chat approvals will
be refused until they register a passkey and re-enrol.
Machine: `MachineHostCredential.ownerApproval`, written once by `env enroll`
and read by nothing else. A malformed answer pins NOTHING rather than
something partial. New read-only `pagespace env owner-keys <enrollmentId>`
shows what the machine will accept, and says plainly that nothing can add to
it. The GDPR Art 15 local-environments collector carries the column (device
data of the same kind as `machinePublicKey`, which it already carried).
Schema: `bun run db:generate` only — 0296, one ADD COLUMN; `db:generate`
afterwards is a no-op and `drizzle-kit check` is clean.
Tests: 6 service rows + 14 CLI rows, red first, from leaf page
j7s9pyxizdu3bye5ypy3s0bn — including the replay row (a second enrol with
passkeys added since cannot widen the pinned set) and the structural row
that no other store method writes the column. Mutation: 11/11 killed by line
index.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…pproval intent (B3)
`ApprovalIntent` gains `assertion { credentialId, authenticatorData,
clientDataJSON, signature }` — all base64url, exactly as
`@simplewebauthn/browser` returns them. It sits inside `encodeGrant`'s
canonical bytes, so the server's signature covers it and it cannot be
swapped, stripped or moved onto another grant in flight (the machine's own
check in B4 is what actually decides).
Byte compatibility is preserved twice over: a grant with no
`approvalIntent` encodes to exactly the bytes it always had, and an intent
WITHOUT an assertion does too — the field is appended only when present.
It is optional on the wire so that its absence is answered with the precise
`approval_unproven` rather than a blanket `malformed` on the whole grant; it
is not optional in effect (B5).
The route's `.strict()` POST body was opened deliberately for it, and the
server RELAYS it rather than verifying and discarding it as the step-up flow
does — the party that must be convinced a human clicked is the machine, and
a check here would be exactly the server attestation this work removes. A
test pins that an assertion that could not verify anywhere still travels.
GET now also answers the WebAuthn options: the DERIVED challenge (B2) and
`allowCredentials` restricted to the set the MACHINE pinned — offering a
passkey registered since enrolment would have the owner touch a key the
daemon then refuses, with nothing to explain it. When the machine pinned
none, the card says so and refuses to send an Allow it knows cannot be
proven, pointing at the terminal instead.
The card runs `startAuthentication` BEFORE the POST (a cancelled prompt
sends nothing at all) and keeps using `fetchWithAuth` for both GET and POST
— a raw fetch answers 403 CSRF_TOKEN_MISSING, a shipped defect that stays
fixed. Deny never runs the ceremony: refusing to run is not the dangerous
direction.
Measured on a real P-256 assertion: 448 bytes of JSON payload, +461 bytes to
the signed grant, and a whole `grant_exec` frame carrying it is 1300 bytes
against the 1 MiB frame limit.
Tests: 6 codec rows, 8 route rows, 5 card rows, red first, from leaf page
e0xbqhxb3zasr6a5jigm614u. Mutation: 6/6 killed by line index.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
This is the leaf that closes the gap. Until now the machine checked the server's Ed25519 signature and that the re-issued request byte-matched the one it froze — and both are satisfiable by whoever holds the signing key AND stands where the server stands, because the daemon hands the challenge id and the frozen request back in its own `ask_pending` reply. The byte-compare then passes BY CONSTRUCTION. The dispatcher now verifies the owner's WebAuthn assertion before it byte-compares: `clientData.type` → the challenge derived from the request THIS machine froze → origin → rpIdHash → user-present → credential in the pinned set → signature, under the credentials pinned at enrolment. Anything else is `approval_unproven`, audited with the precise cause, and nothing runs. Verified before the byte-compare so an unproven click reads as unproven rather than as a mismatch. Fail closed by construction: a daemon with no owner-approval gate wired passes an EMPTY pinned set to the verifier, which refuses `no_pinned_credential` before either primitive is reached — the absence of a proof mechanism is never the absence of a check. `pendingRequestOnTheWire` is gone from the dispatcher in favour of the lib's `pendingRequestForWire`: the projection is now what the `ask_pending` frame carries AND what the challenge derivation hashes, so the two cannot drift. `env connect` passes the credential's pinned set with `envBridgeSha256` and `es256Verify` — ECDSA-P256 over the assertion's ASN.1 DER signature, which `crypto.verify` does natively, so no new dependency. Tests: 13 dispatcher rows, red first (the 5 pre-existing click rows went red first too, and now carry real P-256 assertions rather than nothing), from leaf page ihf41c2amxdob7a7vdg0t00c. Mutation, measured against the REBUILT lib dist (a src-only mutation is invisible to these tests): 6/7 in `owner-approval.ts` with the identity control correctly surviving, and 3/4 in `dispatcher.ts`, again with the control surviving. **The exit-criterion mutant is red:** skip the challenge comparison and an assertion the owner genuinely made for a DIFFERENT frozen request runs — the row that proves the byte-compare alone could not have caught it. The first attempt at the dispatcher sweep was INVALID (its control went red) because the previous sweep had left the lib dist holding a mutant; it was rebuilt and the sweep redone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
… document the whole property (B5 + docs) The property must never be weaker than before. A machine that cannot verify a human must not accept a server's word that there was one. - **Nothing pinned ⇒ the chat path is refused BEFORE a question is frozen.** The daemon does not mint a challenge it could not verify the answer to; with a terminal attached the ask goes there instead, even when the chat is preferred, because the terminal prompt was never exposed to this forgery (a TTY daemon mints no challenge, so a fabricated id dies `unknown_challenge`). Headless and unpinned ⇒ `ask_unavailable:no_owner_credential`, audited. - **A pinned set is used and never fallen back from.** A click on a challenge frozen while credentials existed is still `approval_unproven` if they are gone; an empty set is the same answer as none. - **No path adds a credential.** Not a frame (a new `invariants.test.ts` row in the shape of the existing "no frame adds an approval": the codec has no vocabulary for it, no daemon file writes it, and the one writer is `env enroll`, outside the daemon), not the CLI (`env owner-keys` is read-only by construction, pinned by a test), not the server (only `pinMachineKey` writes the column). A malformed pinning in the credential store is DROPPED rather than trusted — strictly stricter, and it must not also strand the machine identity. This is deliberately STRONGER than the leaf's literal wording. "Adding a credential requires an assertion from an already-pinned one" cannot be honoured from a terminal, which has no authenticator; the fail-closed reading is that there is no add path at all, and re-enrolment is the documented way. Said plainly at `env enroll`, `env connect`, `env owner-keys`, in the README, on the customer page and in the register. Docs, in the same PR: the layers table gains the row it never had — "the human is proven to the machine" — with its files and load-bearing status; the two-remaining-risks section now says the click WAS server-attested and what that meant, and what proving it does and does not buy; the register gains R-13 (closed — the forgery itself), R-14 (the price: an owner with no passkey cannot use chat approvals at all) and R-15 (still open — the assertion binds the click to a frozen request, not to what the browser displayed, bounded by R-6's surface). `packages/cli/README.md` gains the `env owner-keys` verb and a "A chat approval needs your passkey" section; the customer page and CHANGELOG say what a chat approval now requires and what happens without one. Tests: 3 dispatcher rows, 3 `env connect` rows, 11 credential-store rows, 1 invariants row. Mutation: 8/10 with both controls correctly surviving — every real mutant killed, including the codec smuggling a `credentialId` field past the new invariant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…that it stays public-key-only
`drive_env_local`'s guard forbids any column whose name contains
"credential", "secret" or "token" — and hardening B's `ownerCredentials`
trips it. The guard is right to fire, so it is honoured rather than renamed
around: the column is allowlisted BY NAME with the reason (it holds the
owner's passkey PUBLIC keys, which verify signatures and cannot make them,
so a leaked row is still worth nothing — the property the guard actually
defends), and a second row pins that the stored shape stays exactly
`{ credentialId, publicKeyCose }` with nothing that reads like private
material. A future field that could hold a secret has to be added there
first, in front of somebody.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
`export * from './owner-approval'` in the env-bridge barrel and the `OwnerApprovalAssertion` alias were both dead on arrival: nothing imports the barrel (every consumer takes the deep subpath, and the CLI must, for the bundling seam), and the wire name for that shape is `ApprovalAssertion` in `grant.ts`, which is what the verifier and the codec both use. Found by `knip:check`. Note for the reviewer: that command fails on `pu/local-env-ga` itself with five findings (the barrel file, `digestsEqual`, and three `env-contract` types) — verified by running it in a fresh detached worktree of the base, where it reports the same set plus one more. This branch introduces none of them and now reports one fewer than the base. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
The file already imported `readFileSync` from 'fs' and `path` from 'path'; my credential-column guard added a second `node:fs` / `node:path` pair, which is TS2300 and broke the packages/db typecheck in Static Security Analysis. Invisible locally because I ran vitest on the file and not `tsc` — vitest does not typecheck. Full-package tsc for db, lib, cli and web now runs before every push. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…o approval could ever run (Codex P1)
`signGrantFrame` rebuilt `approvalIntent` from `{challengeId, scope,
expiresAt}` only. Every real browser approval therefore reached the machine
with NO assertion, was refused `approval_unproven` — and the pending question
had already been spent by the route before the re-issue, so the owner could
not even retry. The feature did not work at all.
Three suites were green over it: the route relayed the assertion, the daemon
refused a click without one, the codec carried one. None of them crossed the
hop between the route and the wire, which is exactly where it broke.
So the fix is one thing and the test is the important thing. New
`click-end-to-end.test.ts` drives every hop with nothing mocked between them
— the card's POST body → the real route → the real `signGrantFrame` under a
real Ed25519 keyring → the real canonical bytes, JSON round-tripped as a
socket sends them → `decodeFrame` → `verifyGrant` → `verifyOwnerApproval` —
and asserts the assertion survives each one and the daemon ALLOWS the run.
The last three are the daemon's own gates; only the socket is stubbed.
Also covered there: stripping or altering the assertion after signing makes
the grant `bad_signature` (it is under the server's signature), and a click
the owner never signed still arrives `approval_unproven` — the route cannot
invent a proof.
Mutation: reintroducing the exact bug (drop the assertion in the signer) turns
the end-to-end row RED; the identity control survived. Full-package tsc:
db=0 lib=0 cli=0 web=0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…dex P1)
The challenge bound `{domain, envId, challengeId, requestHash}` but not the
scope, so an assertion said only "a human approved this request" — and in
precisely the attack this workstream exists to stop, a server that received a
proof for `once` could relay it as `until_revoked` and the machine would
write a durable approval the owner never granted. One click became a standing
capability.
The scope is now inside the derivation, and the daemon recomputes the
challenge from the scope in the RECEIVED intent — so a relayed proof derives
a different challenge and is refused `approval_unproven:challenge_mismatch`
with nothing run and nothing remembered.
Because the owner picks the scope on the card, after the GET, the route now
derives one challenge PER SCOPE and the card signs the one matching its
selection; a card that has no challenge for the selected scope refuses to
sign rather than signing the wrong one. All four are distinct, so a proof
authorises exactly the scope it was made for.
Tests: 7 new pure rows (each scope pair distinct; all four distinct; the
relay refused at the verifier and the honest scope still allowed), the relay
row at the dispatcher (asserting `writes` stays empty — nothing durable may
come of a relayed proof), the relay row end to end through the real signer,
and two card rows (the selected scope decides which challenge is signed; a
missing one refuses).
Mutation: dropping `scope` from the derivation turns the relay rows RED, and
so does making the daemon recompute from the FROZEN scope instead of the
received one; both controls survived. Full-package tsc: db=0 lib=0 cli=0
web=0 marketing=0.
Docs updated in step: the posture doc's layer row and risk narrative, the
customer page, the CLI README and the changelog all now say the signature
covers the scope as well as the request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…ith (Codex P2) Both registration flows leave SimpleWebAuthn's default `supportedAlgorithmIDs` — `[-8, -7, -257]` — so an existing account can already hold an EdDSA or RSA passkey, while the verifier accepted only EC2/ES256/P-256. Such an owner would get a ceremony that SUCCEEDS and a machine that always refuses, with the pending question consumed: the worst possible shape, and one the owner could do nothing about. Verifying all three rather than restricting registration, because restricting would break credentials people already have. `node:crypto` does each natively — ES256 is ECDSA/SHA-256 over an ASN.1 DER signature, EdDSA is Ed25519 over the raw message, RS256 is RSASSA-PKCS1-v1_5/SHA-256 — so this still costs no dependency. `coseEc2ToJwk` becomes `coseToJwk` and reads the COSE labels BY `kty`, never positionally: -1/-2 are `crv`/`x` for EC2 and OKP but `n`/`e` for RSA, and a parser that assumed one shape would silently misread another. A kty/alg pair that does not match is refused, never coerced. `Es256Verify` becomes `VerifyWebauthnSignature` over a tagged `WebauthnPublicKey` union, so the adapter dispatches on the same tag the parser produces. Tests: real Ed25519 and real 2048-bit RSA credentials verified end to end alongside the existing P-256 one (each with an impostor row under the same pinned credential id), plus a DRIFT GUARD asserting the accepted set equals `[-8, -7, -257]` — SimpleWebAuthn's own default, which neither registration flow overrides. If registration ever narrows or widens, that row fails. Mutation: narrowing the supported set to ES256 alone turns the EdDSA and RS256 rows RED; the three per-algorithm branch guards each got an isolating row and all three are now killed; the RSA modulus floor is killed. The top-level algorithm allowlist survives as defence in depth — the per-kty branches already require an exact alg — and is named as such. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…burn the question (Codex) The route spent the pending approval before re-issuing, so an `approval_unproven` refusal — no passkey pinned, a cancelled or malformed assertion, a proof made for another scope — turned a recoverable error into a dead end: the owner was told "unproven" with nothing left to answer, and the DAEMON had not spent its own challenge (it spends one only on a verified allow), so the two sides disagreed until the TTL. The question is still taken before the re-issue, so two concurrent clicks cannot both run, and is now RESTORED when the machine's answer is not a decision: an `approval_unproven` reply, or a transport failure where the machine never answered at all. A decision still spends it — an allow that ran, a deny, and an `approval_mismatch` (the machine framed a different request, so THIS question is answered and dead). The tool result for an unproven click now says what to fix and that the request is still pending, because it is. Tests: five rows covering both directions — unproven and transport failure leave it answerable (and a second, good click then answers it), while allow, deny and mismatch spend it. Mutation: removing either restore is RED, and so is restoring UNCONDITIONALLY (which would keep a mismatch alive); the control survived. web tsc=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
… stopwatch UNRELATED TO THIS PR's FEATURE — a drive-by flake that was failing CI on this branch and would fail for whoever was unlucky next. `trimTrailing`'s row (wave 2's CodeQL js/polynomial-redos fix, #2583) asserted a 100k-char run resolved in under 200ms, and measured 207ms on a loaded runner. Two things wrong with it: 1. **It measured the runner, not the code.** The regression it guards is catastrophic backtracking, which does not make an input "a bit slower" — it makes it take minutes or never finish. A 200ms threshold is a flake generator with no diagnostic value. 2. **It measured the wrong input.** A run of `)` at the END matches `(?:[)}]+)+$` immediately, so a backtracking implementation would have PASSED it. Backtracking explodes on inputs that FAIL to match. Now split in three: correctness of the trim, a backtracking guard over a FAILING shape (a long run followed by one character outside the set) with a generous 5s ceiling and a comment saying the point is "not catastrophic backtracking, not a benchmark", and the existing structural row that no trailing-anchored `X+$` regex remains. The CodeQL provenance stays in the describe name. Verified by reintroducing the exact defect — `trimTrailing` reimplemented as `(?:[…]+)+$` — which makes the suite HANG, i.e. the guard fires. The old row did not catch that mutant at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…s click authorises Rebase resolution onto hardening A (#2598), plus the one real defect it created. `pendingRequestForWire` (hardening B's shared projection) REPLACED hardening A's local `pendingRequestOnTheWire` in the dispatcher — and dropped `writeModes` on the way. That projection is now load-bearing twice: it is what the `ask_pending` frame carries AND what the assertion's challenge hashes. So the loss would have hidden the executable bit from the owner's card and left the mode outside what their signature authorises. Restored, with a row pinning it: two frozen requests identical except for `writeModes` derive different challenges, so a proof made for a non-executable write cannot be relayed as an executable one. Stated in the test as DEFENCE IN DEPTH rather than overclaimed — hardening A put `writeModes` in `NormalizedRequest`, so the daemon's byte-compare already refuses a re-issued write whose mode changed; what this adds is that the two checks now fail independently. Mutation: dropping the field from the projection is RED, control survived. The other resolutions kept BOTH sides throughout: A's `refuse` naming, audit `paths` and per-file `files` findings over B's shared projection; A's root-is-home warning at enrol beside B's pinning line; both sets of docs and changelog rows. A's sensitive-write click test now carries a real assertion (the click must be proven), and gains a sibling asserting an UNPROVEN click on the same write writes nothing — A escalates, B proves. Suites on the rebased tree: cli 1392, lib 1169, web 446, db 689 (the 8 `activity_logs` failures are pre-existing local `users`-insert breakage, untouched by this branch). tsc db=0 lib=0 cli=0 web=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…cannot reach a hash call
CodeQL `js/insufficient-password-hash` (alert 340) fired on
`click-end-to-end.test.ts:86`, at the two `createHash('sha256')` calls inside
`authenticatorSigns`.
On the merits it is a false positive: WebAuthn MANDATES exactly those two
computations — `authenticatorData` begins with SHA-256 of the RP id, and what
an assertion signs is `authenticatorData || SHA-256(clientDataJSON)`. Neither
hashes a password or any credential; the inputs are a public hostname and a
public JSON blob the browser produces. The taint reaches this fixture only
through the app's import graph from an OAuth token path.
Fixed by restructuring rather than suppressing or weakening: `RP_ID_HASH` is
computed once at module scope from a literal, and the clientDataJSON hash goes
through a tiny named helper that takes a Buffer and nothing else — so no value
CodeQL considers credential-shaped flows into a hash call inside the ceremony.
WHAT IS SIGNED IS UNCHANGED, and that is checked rather than asserted: the
end-to-end row still builds a real `authenticatorData` with the real rpIdHash
and a real signature, and still runs the DAEMON'S OWN verifier over it — a
wrong rpIdHash would be `rp_mismatch` and wrong signed bytes `bad_signature`.
All four rows pass. The same property covers the duplicated literal: if it
drifted from `RP_ID`, every row here would fail `rp_mismatch`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…e reason
Reporting honestly: the restructure did NOT clear the alert. It fired inline
(alert 340), and after hoisting the hashes to module scope it fired again
(alert 341) — the second time on `createHash('sha256').update('pagespace.test')`,
a hash of a STRING LITERAL, which cannot be a password by construction.
CodeQL's taint reaches this fixture through the app's import graph from an
OAuth token path, so no arrangement of code inside this file avoids it.
So: two per-line `codeql[js/insufficient-password-hash]` suppressions, in the
repo's existing convention (`workspace-sprite-key.ts`, `env-sprite-key.ts`,
`app-replay-key.ts`), each naming what is hashed, why WebAuthn requires it,
and the alert numbers. Not a file-level or rule-level disable, and not a
weakened fixture.
The hoisting is kept regardless, because it makes the inputs as narrow as they
can be — a literal and a Buffer — which is what makes the suppressions
checkable by eye.
What is signed is still unchanged and still proven: the end-to-end row builds
a real `authenticatorData` with the real rpIdHash and a real signature, and
runs the DAEMON'S OWN verifier over it. A wrong rpIdHash would be
`rp_mismatch`; wrong signed bytes would be `bad_signature`. Four rows pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…cally, and prove more while doing it The per-line suppressions did not clear alerts 340/341: this repo runs `security-extended` through GitHub's default setup, which does not honour inline `codeql[...]` comments. They are kept, reworded to say plainly that they are documentation and not a control. What clears it is breaking the taint. CodeQL was tracing a value from `validateOAuthAccessToken` / `authenticateOAuthRequest` into the fixture's SHA-256 calls, because the challenge being signed came from the ROUTE's GET response. It is now derived locally by `deriveOwnerApprovalChallenge` over literal inputs, and the route's value is ASSERTED equal to it before signing. That makes the test prove strictly more than it did: every row now pins that the server's per-scope challenge IS the pure derivation, rather than assuming it. The end-to-end row still builds real authenticator data with the real rpIdHash, still signs `authenticatorData || SHA-256(clientDataJSON)`, and still runs the daemon's own verifier over the result — nothing was weakened to suit the analyzer. Four rows pass; web tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
… actual cause of the 5s timeout MEASURED FIRST, and the eager-import hypothesis did NOT hold. Timing `run.test.ts` with the four env routes left eager vs made lazy, three runs each: eager: transform 365/356/371 ms, collect 722/674/783 ms, tests 4.35/3.79/2.90 s lazy: transform 366/666/368 ms, collect 742/1260/685 ms, tests 4.00/3.94/3.72 s Indistinguishable. In the built dist the whole env subtree is ~35 ms (`lib-core`), ~35 ms (`commands/env`) and ~65-89 ms (`env/connect`) against a 382 ms route-table import, and the test BODY — not collection — is what takes seconds. So the env import is not what blows the budget, and the routes are left eager. (Whether the published CLI should load the bridge lazily is a real question, but it is a startup-cost question, not this failure, and it belongs with the open bundle-or-publish decision rather than here.) THE ACTUAL CAUSE, found by instrumenting `fetch`: `makeDeps` fakes the credential store but nothing faked the network, so every row that resolves a credential and runs a command makes REAL outbound requests to the production host. `run(['whoami'])` alone does two — `https://pagespace.ai/api/auth/me` and `/api/drives`. Locally that is ~235 ms and invisible. On a CI runner where egress to that host is blocked or slow it hangs until the socket gives up, which is exactly `Test timed out in 5000ms`, intermittently, on whichever branch drew an unlucky runner. That is why it tracked no code change and why it "passed on retry". Fixed at the cause: `fetch` is stubbed for the whole file (401, the honest answer for a fake credential — every row asserts on what the CLI SAYS, never on a server payload), and a new row pins that the stub is load-bearing by naming the exact production URL this file used to hit, so removing it fails readably instead of becoming a timeout somebody calls flake. The flagged test now runs below vitest's reporting threshold; 42 rows pass, and the whole CLI suite is 1393 green. WHY THIS IS IN A PASSKEY PR: it was the last red check on #2599 and it also fails on `pu/local-env-ga`, so it blocks this branch and #2590. It is a test fix only — no production code changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
96d404c to
f21574b
Compare
Why
The founder asked what would actually have to go wrong for someone's computer to be exposed. Answering it against the merged GA code found that the whole Tier B design rests on a check the machine never performed.
The design says:
execnever runs headless, because the owner clicks in the chat and the machine verifies the click. The machine verified two things — the Ed25519 signature on the grant under the server key pinned at enrolment, and that the re-issued request byte-matched the one it froze. Neither is evidence that a human was there. The owner-only check lived entirely in the web route (approvals/[challengeId]/route.ts), which the daemon cannot observe.So an attacker holding the signing key and a position as the server the daemon dials could drive the loop alone: send a grant → the daemon replies
ask_pendingand hands back the challenge id and the frozen request (dispatcher.ts:284) → sign a second grant carryingapprovalIntent { challengeId, scope: 'until_revoked' }→ the byte-match passes by construction → it runs, and persists for 30 days or forever.The uncomfortable part: this worked only in chat-ask mode. A TTY-attached daemon never mints a challenge, so a forged id died
unknown_challenge. The headless path built for GA was the exposed one.Nothing was ever exposed —
LOCAL_ENVS_ENABLEDis off on every deployment — so this is pre-release work, not incident response.The one-sentence proof: a server holding the signing key can no longer make a machine run anything a human did not approve.
What changed, per leaf
B2 · Bind the assertion to the frozen request (
1c56ee16b)New pure module
packages/lib/src/env-bridge/owner-approval.ts. The WebAuthn challenge is derived, not random:with a sixth domain constant beside the existing five (
OWNER_APPROVAL_SIGNING_DOMAIN), so an assertion cannot be moved to another request, another pending question, another environment, or another message type. Both sides derive it independently — the server from the frozen request the machine signed into itsgrant_denied, the daemon from the request it actually froze — and the daemon's derivation is what decides.The request hash covers the whole wire request via
canonicalizeArgsrather than a hand-listed projection, so a field the frozen request gains later (workstream A'swriteModes) is covered without an edit here. There is a test for exactly that.Also lands the verifier (wired in B4) and
coseEc2ToJwk: nothing in the repo parsed CBOR, so it reads exactly the subset a COSE_Key can legally be and refuses everything else withnull— total by construction.B1 · Pin the owner's passkeys at enrolment (
9e0248ea7)Trust on first use, at the one moment the owner is provably at the keyboard — the same shape the server signing key already uses, in the other direction. PageSpace has had passkeys all along; what was missing is that
passkeys.publicKeynever reached any client, so the machine had no way to recognise its owner.ownerCredentials { rpId, origin, credentials[] }; the same update that pins the machine key writes them to a newdrive_env_local.ownerCredentialsjsonb column.pinMachineKeyis the only writer of that column — a test reads the store source and pins that.listPasskeyPublicKeysis the one place a COSE public key leaves the server.owner_credentials_unavailable, 503) without spending the code: pinning an empty set on a database hiccup would leave an owner who does have a passkey permanently unable to approve in chat, with nothing to explain it.MachineHostCredential.ownerApproval, written once byenv enroll. A malformed answer pins nothing rather than something partial. New read-onlypagespace env owner-keys <enrollmentId>.machinePublicKey, already carried).B3 · Carry the assertion to the machine (
740ef134b)ApprovalIntentgainsassertion { credentialId, authenticatorData, clientDataJSON, signature }, insideencodeGrant's canonical bytes so the server's signature covers it.Byte compatibility preserved twice over, both tested: a grant with no
approvalIntentencodes to exactly the bytes it always had, and an intent without an assertion does too.The route's
.strict()POST body was opened deliberately, and the server relays the assertion rather than verifying and discarding it as the step-up flow does — a check there would be exactly the server attestation this work removes. A test pins that an assertion which could not verify anywhere still travels.GET now also answers the derived challenge and
allowCredentialsrestricted to the set the machine pinned — offering a passkey registered since enrolment would have the owner touch a key the daemon then refuses, with nothing to explain it. The card runsstartAuthenticationbefore the POST (a cancelled prompt sends nothing at all) and keeps usingfetchWithAuthfor both GET and POST.Measured size of a real assertion inside a grant frame (real P-256 key, real
clientDataJSON, real DER signature):grant_execframe carrying itDEFAULT_FRAME_LIMITS)B4 · The daemon verifies it (
fb8dd73b2) — the exit criterionThe dispatcher verifies the assertion before the byte-compare, in a fixed order:
clientData.type→ the challenge derived from the request this machine froze → origin →rpIdHash→ user-present → credential in the pinned set → signature. Anything else isapproval_unproven, audited with the precise cause, and nothing runs.Fail closed by construction: a daemon with no gate wired passes an empty pinned set to the verifier, which refuses
no_pinned_credentialbefore either primitive is reached — the absence of a proof mechanism is never the absence of a check.pendingRequestOnTheWiremoved out of the dispatcher into the lib'spendingRequestForWire: that projection is now what theask_pendingframe carries and what the challenge derivation hashes, so the two cannot drift.ES256 is P-256 with an ASN.1 DER signature, which
crypto.verifydoes natively — no new dependency anywhere.B5 · Fail closed, and the docs (
c03effd38)invariants.test.tsrow, in the shape of the existing "no frame adds an approval"), not the CLI, not the server.Docs
docs/security/local-environment-bridge.md: the layers table gains the row it never had — "the human is proven to the machine" — with its files and load-bearing status; the two-remaining-risks section now says the click was server-attested, what that meant, and what proving it does and does not buy; the register gains R-13 (closed — the forgery), R-14 (the price: an owner with no passkey cannot use chat approvals at all) and R-15 (still open — the assertion binds the click to a frozen request, not to what the browser displayed; bounded by R-6's surface).packages/cli/README.mdgains theenv owner-keysverb and a "A chat approval needs your passkey" section. Customer page andCHANGELOG.mdsay what a chat approval now requires and what happens without one. Every cited path verified present.Verification
Tests, red first, from the leaf pages. 52 pure rows (B2/B4), 6 service + 14 CLI rows (B1), 6 codec + 8 route + 5 card rows (B3), 13 dispatcher rows (B4), 3 dispatcher + 3 connect + 11 credential-store + 1 invariants row (B5). The 5 pre-existing click tests went red first too, and now carry real P-256 assertions rather than nothing.
Mutation, by line index, with a no-op control in every sweep:
owner-approval.ts(verifier)owner-approval.ts(COSE parser)drive-envs.ts(pinning)env.ts(CLI pinning)grant.ts/ route / card (B3)owner-approval.tsvia built dist (B4)dispatcher.ts(B4 wiring)The exit-criterion mutant is red: skip the challenge comparison and an assertion the owner genuinely made for a different frozen request runs — the row that proves the byte-compare alone could never have caught it.
Gates.
bunx tsc --noEmitclean inlib,db,cli,webandmarketing. Affected suites green: CLI 1355, lib env-bridge 628, lib drive-envs + auth 1769, web env-bridge/card/tools 431, db 735.Exports maps. New lib subpath
./env-bridge/owner-approvaladded topackages/lib/package.json(a missing entry fails CI invisibly). No new db subpath. The CLI bundling seam holds: everything goes throughlib-core.ts, andpublished-entry-no-lib.test.tsstill proves no@pagespace/libspecifier survives in the built dist.Schema.
bun run db:generateonly —0296, oneADD COLUMN. Re-runningdb:generateafterwards is "No schema changes, nothing to migrate 😴" anddrizzle-kit checkis "Everything's fine 🐶🔥". Table-level GDPR coverage is unaffected (existing table); the column is carried in the Art 15 collector anyway.drive_env_local's "no column resembling credential/secret/token" guard fired onownerCredentials, correctly. Honoured rather than renamed around: the column is allowlisted by name with the reason (public keys verify signatures and cannot make them, so a leaked row is still worth nothing — the property the guard defends), plus a new row pinning that the stored shape stays{ credentialId, publicKeyCose }with nothing that reads like private material.knip.
knip:checkfails onpu/local-env-gaitself with five findings; verified by running it in a fresh detached worktree of the base, where it reports the same set plus one more. This branch introduces none of them and now reports one fewer, after dropping two exports nothing consumed.Known local-only failures, both pre-existing and unrelated:
activity-logs-compliance.test.ts(ausersinsert failing locally; my diff touches no users/activity_logs code) andgdpr-eraser.integration.test.ts(ADMIN_DATABASE_URLunset). CI is the gate.What this does not fix
The assertion proves a human with the owner's authenticator pressed a key for this request — not that they read it. And a compromised server still chooses which frozen request the card displays beside the ceremony, so it could show one pending request's text and obtain an assertion for another. It can only ever do that with a request the machine itself framed and the owner assented to — never one it invented — so the reachable set is bounded by what the owner's own agent asked for, which is exactly R-6's surface. Recorded as R-15.
Review round 1 — what the review found, and what it cost
Codex found three, and the first meant the feature did not work at all. All three are fixed, one commit each, red first. Everything below was re-verified on
pu/local-env-gaafter hardening A (#2598) merged; this branch is rebased ontofda7e618c.P1 · The signer dropped the assertion (
822015b92)signGrantFramerebuiltapprovalIntentfrom{challengeId, scope, expiresAt}only, so every real browser approval reached the machine unproven, was refusedapproval_unproven— and the route had already spent the pending question, so the owner could not even retry.Three suites were green over it: the route relayed the assertion, the daemon refused a click without one, the codec carried one. None crossed the hop between the route and the wire, which is exactly where it broke. So the fix is one line and the test is the point:
click-end-to-end.test.tsdrives the card's POST body → the real route → the realsignGrantFrameunder a real Ed25519 keyring → the real canonical bytes, JSON round-tripped as a socket sends them →decodeFrame→verifyGrant→verifyOwnerApproval, asserting the assertion survives every hop and the daemon allows the run. Only the socket is stubbed. Reintroducing the exact bug turns that row red.P1 · The scope was not bound (
405c1c985)The challenge bound
{domain, envId, challengeId, requestHash}but not the owner's chosen scope — so in precisely the attack this exists to stop, a server receiving a proof foroncecould relay it asuntil_revokedand the machine would write a durable approval the owner never granted. One click became a standing capability.scopeis now in the derivation, and the daemon recomputes from the scope in the received intent. Because the owner picks the scope after the GET, the route derives one challenge per scope and the card signs the one matching its selection; a card with no challenge for the selected scope refuses to sign rather than signing the wrong one. Mutation: droppingscopeturns the relay rows red, and so does recomputing from the frozen scope instead of the received one.P2 · Only one algorithm was verifiable (
a9f9b4ae0)Both registration flows leave SimpleWebAuthn's default
supportedAlgorithmIDs=[-8, -7, -257], so an account can already hold an EdDSA or RSA passkey, while the verifier accepted only ES256. Those owners would get a ceremony that succeeds and a machine that always refuses, with the question consumed.Verifying all three rather than restricting registration, since restricting would break credentials people already have.
coseEc2ToJwkbecomescoseToJwkand reads COSE labels bykty, never positionally (-1/-2 arecrv/xfor EC2 and OKP butn/efor RSA). Real Ed25519 and real 2048-bit RSA credentials are now verified end to end, plus a drift guard asserting the accepted set equals SimpleWebAuthn's own default — if registration ever narrows or widens, that row fails.Shared · A refusal that is not the owner's decision no longer burns the question (
1b4e77ebe)The route spent the pending approval before re-issuing, so
approval_unproventurned a recoverable error into a dead end — and the daemon had not spent its challenge (it spends one only on a verified allow), so the two sides disagreed until the TTL. It is still taken before the re-issue (two concurrent clicks must not both run) and now restored when the answer is not a decision:approval_unproven, or a transport failure where the machine never answered. A decision still spends it — allow, deny, andapproval_mismatch. The tool result says what to fix and that the request is still pending.Rebase onto hardening A, and the defect it created (
2aea0fef8)Resolved keeping both sides throughout: A's
refusenaming, auditpathsand per-filefilesfindings over B's shared projection; A's root-is-home warning at enrol beside B's pinning line; both sets of docs and changelog rows.One real defect: B's
pendingRequestForWirereplaced A's local projection and droppedwriteModes. That projection is load-bearing twice — theask_pendingframe and the challenge hash — so the loss would have hidden the executable bit from the card and left the mode outside what the signature authorises. Restored, with a row pinning it, stated as defence in depth rather than overclaimed: A putwriteModesinNormalizedRequest, so the byte-compare already refuses a re-issued write whose mode changed; what this adds is that the two checks now fail independently. A's sensitive-write click test carries a real assertion, and gains a sibling asserting an unproven click on the same write writes nothing.Unrelated flake, fixed in passing (
feeb97ebd)decide-approval.test.ts'strimTrailingrow (wave 2's CodeQL js/polynomial-redos fix, #2583) asserted a 100k-char run resolved in under 200ms and measured 207ms on a loaded runner. It was also measuring the wrong input: a run of)at the end matches that regex immediately, so a backtracking implementation would have passed it. Now split into correctness, a backtracking guard over a failing shape with a generous 5s ceiling ("not catastrophic backtracking, not a benchmark"), and the existing structural row. Verified by reimplementingtrimTrailingas the polynomial regex: the suite hangs, i.e. the guard fires — the old row did not catch that mutant at all. In this PR only because it was failing CI on this branch.Also
3493f43f0: a duplicatereadFileSyncimport I added in the schema-guard commit broke thepackages/dbtypecheck in Static Security Analysis — invisible locally because I ran vitest on the file and nottsc. Full-packagetscfor db, lib, cli and web now runs before every push.Re-measured on the rebased base
grant_execframeFrame limit is 1 048 576 B; the largest is 0.15% of it. The earlier single-row table in this description was measured pre-rebase and is superseded by this one.
B4 mutation batch re-run against the merged base, against the rebuilt lib dist: control survived, 7/7 real mutants red, including the exit criterion (skip the challenge comparison ⇒ an assertion the owner genuinely made for a different frozen request runs).
Suites on the rebased tree: cli 1392, lib 1169, web 446, db 689. Typechecks db=0 lib=0 cli=0 web=0 marketing=0.
Two pre-existing local failures, neither touched by this branch:
activity-logs-compliance.test.ts(8 rows; ausersinsert failing locally) andgdpr-eraser.integration.test.ts(ADMIN_DATABASE_URLunset). CI is the gate.One CI failure that did not reproduce
Security Test Suite → Run SDK and CLI package suitesfailed once onaa6a0f507(run34400806584):run.test.ts > folds the legacy PAGESPACE_AUTH_TOKEN env var into the single auth-resolution path—Test timed out in 5000ms, 1 failed / 1730 passed. It passed on324790e57(run34401953904) with no change to that test or its dependencies.Investigated rather than assumed, because B1 touched both
run.tsandrouter/routes.ts:Setentry and a route row, both O(1) at dispatch.run.ts's import graph:owner-keys.tsimports onlycredentials/store.js,credentials/serialize.js,commands/env.jsandenv-bridge/lib-core.js, every one of whichrun.tsalready reached throughenvEnrollHandler/envConnectHandler.createCredentialStore()is called lazily inside the handler, never at module load, so nothing new is awaited on that test's path.Recorded as did not reproduce; treated as a flake, with no latent slow path found. The
testTimeoutwas not touched.CodeQL, and what actually fixed it
js/insufficient-password-hashfired on the end-to-end fixture's two SHA-256 calls (alerts 340, then 341 after a first restructure). On the merits it is a false positive — WebAuthn requires both: authenticator data begins with SHA-256 of the RP id, and an assertion signsauthenticatorData || SHA-256(clientDataJSON). Nothing near it is a password; the taint arrived through the app's import graph from an OAuth token path.Three attempts, reported in order because the first two did not work:
RP_ID_HASHfrom a module-scope literal, the clientDataJSON hash behind aBuffer-only helper. It fired again, alert 341, this time on the hash of a string literal.codeql[...]suppressions. Also did not clear it: this repo runssecurity-extendedthrough GitHub's default setup, which does not honour inline suppressions. The comments are kept, reworded to say plainly that they are documentation and not a control.96d404c87): the challenge being signed is now derived locally by the purederiveOwnerApprovalChallengeover literal inputs, and the route's value is asserted equal to it before signing, instead of the route's value being fed into the hash.The third is also the better test. Every row now pins that the server's per-scope challenge is the pure derivation rather than assuming it, and nothing was weakened: the fixture still builds real authenticator data with the real rpIdHash, still signs
authenticatorData || SHA-256(clientDataJSON), and still runs the daemon's own verifier over the result. Nopaths-ignore, no alert dismissal, no disabled job.Rebase onto the regenerated migration chain, and two investigations
The migration was re-derived on the new chain
pu/local-env-gatook master's drizzle chain wholesale and collapsed the branch's migrations into0292_round_robbie_robertson.sql, which orphaned this branch's0296_*. Resolved as instructed: the0296SQL and snapshot are deleted, the base's journal taken wholesale, and the column re-emitted bydb:generateas a new migration on top of0292.packages/db/drizzle/0293_serious_devos.sql, in full:Proof, both required:
No drizzle file was hand-edited. Nothing in the PR body or docs cited a migration by filename, so nothing needed updating there. The GDPR export coverage gate still passes for the column (
gdpr-export-coverage.test.ts+gdpr-export.test.ts, 53 rows green) —drive_env_localwas already a registered exported table and the column rides its existing collector.One local-only wrinkle worth recording: the migration runner is hash-keyed, so the regenerated
0292re-runs against a database that already applied the old chain (relation "drive_env_grant_audit" already exists). That is scratch-database drift, not a branch problem — CI starts empty. Dropping and recreating the local test DB applies the whole chain cleanly from scratch.The
run.test.ts5 s timeout — hypothesis measured, then the real cause foundThe eager-env-import hypothesis was measured and did not hold. Timing
run.test.tswith the four env routes eager vs lazy, three runs each:Indistinguishable. In the built dist the whole env subtree is ~35 ms (
lib-core), ~35 ms (commands/env) and ~65–89 ms (env/connect) against a 382 ms route-table import — and it is the test body, not collection, that takes seconds. So the routes are left eager. Whether the published CLI should load the bridge lazily is a real question, but it is a startup-cost question rather than this failure, and belongs with the open bundle-or-publish decision.The actual cause, found by instrumenting
fetch:makeDepsfakes the credential store but nothing faked the network, so every row that resolves a credential and runs a command made real outbound requests to the production host —run(['whoami'])alone did two,https://pagespace.ai/api/auth/meand/api/drives. Locally ~235 ms and invisible; on a runner where egress to that host is blocked or slow it hangs until the socket gives up, which is preciselyTest timed out in 5000ms, intermittently, on whichever branch drew an unlucky runner. That is why it tracked no code change and why it passed on retry.Fixed at the cause (
f21574bb4):fetchis stubbed for the whole file, and a new row pins the stub as load-bearing by naming the exact production URL the file used to hit, so removing it fails readably instead of becoming a timeout somebody calls flake. The flagged test now runs below vitest's reporting threshold; 42 rows pass and the CLI suite is 1393 green. Test-only change — no production code touched. It is in this PR because it was the last red check here and also fails onpu/local-env-ga, so it blocks this branch and #2590.(This supersedes the earlier "did not reproduce; treated as a flake" note above — it did reproduce, and it was not a flake.)
Typechecks after the rebase: db=0 lib=0 cli=0 web=0 marketing=0.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id