Skip to content

feat(env-bridge): the machine verifies the owner's click itself — a passkey assertion bound to the frozen request (hardening B) - #2599

Merged
2witstudios merged 18 commits into
pu/local-env-gafrom
pu/local-env-hard-b
Sep 9, 2026
Merged

2witstudios merged 18 commits into
pu/local-env-gafrom
pu/local-env-hard-b

Conversation

@2witstudios

@2witstudios 2witstudios commented Sep 9, 2026

Copy link
Copy Markdown
Owner

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: exec never 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_pending and hands back the challenge id and the frozen request (dispatcher.ts:284) → sign a second grant carrying approvalIntent { 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_ENABLED is 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:

challenge   = base64url(SHA256(canonicalizeArgs({ domain, envId, challengeId, requestHash })))
requestHash = base64url(SHA256(canonicalizeArgs(frozen request)))

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 its grant_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 canonicalizeArgs rather than a hand-listed projection, so a field the frozen request gains later (workstream A's writeModes) 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 with null — 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.publicKey never reached any client, so the machine had no way to recognise its owner.

  • Enroll answers ownerCredentials { rpId, origin, credentials[] }; 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.
  • listPasskeyPublicKeys is the one place a COSE public key leaves the server.
  • 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 with genuinely none pins an empty set — distinguishable from "never pinned" — and is told so at the terminal.
  • CLI: MachineHostCredential.ownerApproval, written once by env enroll. A malformed answer pins nothing rather than something partial. New read-only pagespace env owner-keys <enrollmentId>.
  • The Art 15 local-environments collector carries the column (device data of the same kind as machinePublicKey, already carried).

B3 · Carry the assertion to the machine (740ef134b)

ApprovalIntent gains assertion { credentialId, authenticatorData, clientDataJSON, signature }, inside encodeGrant's canonical bytes so the server's signature covers it.

Byte compatibility preserved twice over, both tested: a grant with no approvalIntent encodes 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 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. The card runs startAuthentication before the POST (a cancelled prompt sends nothing at all) and keeps using fetchWithAuth for both GET and POST.

Measured size of a real assertion inside a grant frame (real P-256 key, real clientDataJSON, real DER signature):

bytes
assertion JSON payload 448
signed grant, intent only 530
signed grant, with assertion 991 (+461)
whole grant_exec frame carrying it 1300
frame limit (DEFAULT_FRAME_LIMITS) 1 048 576

B4 · The daemon verifies it (fb8dd73b2) — the exit criterion

The 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 is approval_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_credential before either primitive is reached — the absence of a proof mechanism is never the absence of a check.

pendingRequestOnTheWire moved out of the dispatcher into the lib's pendingRequestForWire: that projection is now what the ask_pending frame carries and what the challenge derivation hashes, so the two cannot drift.

ES256 is P-256 with an ASN.1 DER signature, which crypto.verify does natively — no new dependency anywhere.

B5 · Fail closed, and the docs (c03effd38)

  • 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 pinned set is used and never fallen back from: a click on a challenge frozen while credentials existed is still refused if they are gone.
  • No path adds a credential — not a frame (new invariants.test.ts row, in the shape of the existing "no frame adds an approval"), not the CLI, not the server.

Deviation from the leaf's literal wording, stated deliberately. B5 says adding a credential should "require an assertion from an already-pinned credential". That 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 — strictly stronger. Said plainly at env enroll, env connect, env owner-keys, in the README, on the customer page and in the register.

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.md gains the env owner-keys verb and a "A chat approval needs your passkey" section. Customer page and CHANGELOG.md say 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:

Target Killed Notes
owner-approval.ts (verifier) 13/13 control survived
owner-approval.ts (COSE parser) 4/6 two named in the source as provably subsumed by the trailing-byte guard, which is killed
drive-envs.ts (pinning) 4/4
env.ts (CLI pinning) 7/7
grant.ts / route / card (B3) 6/6
owner-approval.ts via built dist (B4) 6/7 control survived; exit-criterion mutant red
dispatcher.ts (B4 wiring) 3/4 control survived
B5 (dispatcher, connect, serialize, codec) 8/10 both controls survived

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.

One sweep was invalid and was redone. The first dispatcher.ts sweep reported 3/3 red, but its control also went red: the previous sweep had restored owner-approval.ts's source while leaving the built lib dist holding a mutant, and the CLI tests import lib from dist. Rebuilt and redone; the control then survived. Every B4 figure above is measured against a rebuilt artifact.

Gates. bunx tsc --noEmit clean in lib, db, cli, web and marketing. 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-approval added to packages/lib/package.json (a missing entry fails CI invisibly). No new db subpath. The CLI bundling seam holds: everything goes through lib-core.ts, and published-entry-no-lib.test.ts still proves no @pagespace/lib specifier survives in the built dist.

Schema. bun run db:generate only — 0296, one ADD COLUMN. Re-running db:generate afterwards is "No schema changes, nothing to migrate 😴" and drizzle-kit check is "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 on ownerCredentials, 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:check fails on pu/local-env-ga itself 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 (a users insert failing locally; my diff touches no users/activity_logs code) and gdpr-eraser.integration.test.ts (ADMIN_DATABASE_URL unset). 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-ga after hardening A (#2598) merged; this branch is rebased onto fda7e618c.

P1 · The signer dropped the assertion (822015b92)

signGrantFrame rebuilt approvalIntent from {challengeId, scope, expiresAt} only, so every real browser approval reached the machine unproven, was refused approval_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.ts drives 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 → decodeFrameverifyGrantverifyOwnerApproval, 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 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.

scope is 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: dropping scope turns 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. coseEc2ToJwk becomes coseToJwk and reads COSE labels by kty, never positionally (-1/-2 are crv/x for EC2 and OKP but n/e for 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_unproven turned 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, and approval_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 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.

One real defect: B's pendingRequestForWire replaced A's local projection and dropped writeModes. That projection is load-bearing twice — the ask_pending frame 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 put writeModes in NormalizedRequest, 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's trimTrailing 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. 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 reimplementing trimTrailing as 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 duplicate readFileSync import I added in the schema-guard commit broke the packages/db typecheck in Static Security Analysis — invisible locally because I ran vitest on the file and not tsc. Full-package tsc for db, lib, cli and web now runs before every push.

Re-measured on the rebased base

algorithm assertion payload grant (intent only) grant (+assertion) delta whole grant_exec frame
ES256 449 B 530 B 992 B +462 B 1 301 B
EdDSA 440 B 530 B 983 B +453 B 1 292 B
RS256 696 B 530 B 1 239 B +709 B 1 548 B

Frame 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; a users insert failing locally) and gdpr-eraser.integration.test.ts (ADMIN_DATABASE_URL unset). CI is the gate.

One CI failure that did not reproduce

Security Test Suite → Run SDK and CLI package suites failed once on aa6a0f507 (run 34400806584): run.test.ts > folds the legacy PAGESPACE_AUTH_TOKEN env var into the single auth-resolution pathTest timed out in 5000ms, 1 failed / 1730 passed. It passed on 324790e57 (run 34401953904) with no change to that test or its dependencies.

Investigated rather than assumed, because B1 touched both run.ts and router/routes.ts:

  • The test runs in ~300–325 ms locally across four runs on this branch, against a 5 000 ms per-test budget — 16× headroom, and no upward drift.
  • My addition to those two files is a Set entry and a route row, both O(1) at dispatch.
  • It introduces no new module into run.ts's import graph: owner-keys.ts imports only credentials/store.js, credentials/serialize.js, commands/env.js and env-bridge/lib-core.js, every one of which run.ts already reached through envEnrollHandler / 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 testTimeout was not touched.

CodeQL, and what actually fixed it

js/insufficient-password-hash fired 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 signs authenticatorData || 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:

  1. Restructured so the inputs were as narrow as possible — RP_ID_HASH from a module-scope literal, the clientDataJSON hash behind a Buffer-only helper. It fired again, alert 341, this time on the hash of a string literal.
  2. Per-line codeql[...] suppressions. Also did not clear it: this repo runs security-extended through 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.
  3. Broke the taint, which worked (96d404c87): the challenge being signed is now derived locally by the pure deriveOwnerApprovalChallenge over 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. No paths-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-ga took master's drizzle chain wholesale and collapsed the branch's migrations into 0292_round_robbie_robertson.sql, which orphaned this branch's 0296_*. Resolved as instructed: the 0296 SQL and snapshot are deleted, the base's journal taken wholesale, and the column re-emitted by db:generate as a new migration on top of 0292.

packages/db/drizzle/0293_serious_devos.sql, in full:

ALTER TABLE "drive_env_local" ADD COLUMN "ownerCredentials" jsonb;

Proof, both required:

$ bun run db:generate          # second run
No schema changes, nothing to migrate 😴

$ cd packages/db && bunx drizzle-kit check
Everything's fine 🐶🔥

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_local was 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 0292 re-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.ts 5 s timeout — hypothesis measured, then the real cause found

The eager-env-import hypothesis was measured and did not hold. Timing run.test.ts with the four env routes eager vs lazy, three runs each:

transform collect tests
eager (today) 365 / 356 / 371 ms 722 / 674 / 783 ms 4.35 / 3.79 / 2.90 s
lazy (experiment) 366 / 666 / 368 ms 742 / 1260 / 685 ms 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 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: makeDeps fakes 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 hostrun(['whoami']) alone did two, https://pagespace.ai/api/auth/me and /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 precisely 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 (f21574bb4): fetch is 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 on pu/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

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 7f003ec8-18ac-44b9-a9f5-2ebbfb2a8968

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 }) },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 → decodeFrameverifyGrantverifyOwnerApproval — 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.

Comment on lines +117 to +121
domain: OWNER_APPROVAL_SIGNING_DOMAIN,
envId: binding.envId,
challengeId: binding.challengeId,
requestHash: ownerApprovalRequestHash(binding.request, sha256),
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

2witstudios and others added 18 commits September 9, 2026 16:33
…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
@2witstudios
2witstudios merged commit 34f4d6e into pu/local-env-ga Sep 9, 2026
11 checks passed
@2witstudios
2witstudios deleted the pu/local-env-hard-b branch September 10, 2026 04:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants