instar 1.3.1255 → 1.3.1256

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ # Side-Effects Review — Passkey-capable repair browser (Increment 3)
2
+
3
+ **Version / slug:** `passkey-inc3-browser`
4
+ **Date:** `2026-09-22`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `independent subagent (browser drives credential-bearing sign-ins)`
7
+
8
+ ## Summary of the change
9
+
10
+ Increment 3 of the approved spec `docs/specs/agent-held-google-passkey.md` (§3.5 browser foundation,
11
+ §3.1 in-browser credential lifetime, §2 fixture):
12
+
13
+ - **`src/core/PasskeyBrowserPolicy.ts` (new).** Pure rules for WHERE the passkey may be present:
14
+ the credential is added only while the top-level frame is on the holder origin and no other frame
15
+ is on a different relying-party-family origin; it is removed at request time before any top-level
16
+ navigation away from the holder origin, and before any frame's document request to another
17
+ RP-family origin; unrelated-origin frames are allowed. Parameterised by an `OriginPolicy` so the
18
+ same code runs against the local fixture; production uses the Google policy.
19
+ - **`src/core/ChromeCdpReloginBrowser.ts` (extended).** A `passkeyMode` option: CDP over the
20
+ debugging pipe (no TCP port, no `DevToolsActivePort`), `--disable-extensions`,
21
+ `--disable-component-extensions-with-background-pages`, prerendering off; every page/iframe target
22
+ auto-attached (paused at creation) and PREPARED — Page/Runtime/Network enabled, service workers
23
+ bypassed, `Fetch` interception for Document requests, `WebAuthn.enable` + a virtual authenticator,
24
+ the loaded credential added — before it is resumed; a `Fetch.requestPaused` handler that removes the
25
+ credential everywhere before continuing a document request the policy forbids, and fails the
26
+ request closed if removal fails. New port methods: `addCredential`, `removeCredential`,
27
+ `hasCredential`, `observedAssertion` (from `WebAuthn.credentialAsserted`), `exportCredentials`,
28
+ `credentialCount(s)`, `readSignedInIdentityMatches` (boolean only), `sawNavigationBeforeAttach`,
29
+ `clickSelector`, `runInPage`. Clicks now use REAL pointer events (`Input.dispatchMouseEvent`) in
30
+ BOTH modes, including the existing `chooseExpectedAccount`, `click` and the submit step of
31
+ `fillAndSubmit`; fills read the value back before submitting. The legacy TCP path is otherwise
32
+ unchanged and remains the default (`passkeyMode` is off unless a caller sets it).
33
+ - **`src/core/PasskeyWebAuthnFixture.ts` (new).** Two local HTTP origins on `localhost` that stand in
34
+ for accounts.google.com and another google.com subdomain, with pages that create and assert
35
+ resident credentials. Used by the integration suite now and by the Chrome-version self-check later.
36
+
37
+ No caller constructs the browser in passkey mode yet (`server.ts` still uses the default), so there is
38
+ no production behaviour change from passkey mode itself. The real-click change does reach the
39
+ existing repair path.
40
+
41
+ ## Decision-point inventory
42
+
43
+ - `mustRemoveCredentialBefore` / `mayAddCredential` — add — invariants over origins (spec §10 rows "Credential-affecting browser actions" / all-frames rule).
44
+ - `Fetch.requestPaused` handler — add — applies the policy; fail-closed on removal error.
45
+ - Real-click substitution in existing repair actions — modify — no decision change; input mechanics only.
46
+
47
+ ---
48
+
49
+ ## 1. Over-block
50
+
51
+ - In passkey mode, a document request whose URL cannot be parsed removes the credential (fail closed).
52
+ - Real clicks need a visible, non-zero-size element; a hidden button that `element.click()` used to
53
+ reach now raises `browser-element-not-found`. The existing integration test (real Chrome) still
54
+ passes, and Google's list items REQUIRE real clicks (measured 2026-09-21), so this is the right
55
+ trade.
56
+ - Fills now refuse to submit when the value did not stick (read-back length check). Before, an empty
57
+ submit cost a failed attempt on the account.
58
+
59
+ ## 2. Under-block
60
+
61
+ - A same-origin (holder) frame can still request an assertion — by design; that is the sign-in page.
62
+ - Prerendering is disabled by feature flag; if Chrome renames the feature, prerender targets would be
63
+ auto-attached like any other target and prepared before running, so the interception still holds,
64
+ but the flag itself would be inert. The fixture suite would not notice a rename; noted for the
65
+ Chrome-version gate increment. <!-- tracked: CMT-544 -->
66
+ - `sawNavigationBeforeAttach` observes from `Page.enable` onward, so it can confirm a breach it saw
67
+ but not prove absence. The load-bearing guarantee is Chrome's `waitForDebuggerOnStart` pause
68
+ (a target cannot navigate until it is resumed), which the popup tests exercise directly.
69
+ - `readSignedInIdentityMatches` is a generic structural reader (attributes, then exact-text leaf
70
+ elements). The closed `google-account-identity` page class with its fixture lands with the
71
+ enrollment increment; until then it is used only by tests.
72
+
73
+ ## 3. Level-of-abstraction fit
74
+
75
+ The policy is a pure module; the browser applies it at the one place every document request passes
76
+ (Fetch interception at the Request stage). The port grows only by the methods the spec names.
77
+
78
+ ## 4. Signal vs authority compliance
79
+
80
+ Origin rules are deterministic invariants over URLs, not content detectors, and they can only remove
81
+ or refuse — never act on the account. The supervisor is untouched.
82
+
83
+ ## 4b. Judgment-point check (Judgment Within Floors standard)
84
+
85
+ Invariants (spec §10). No competing signals.
86
+
87
+ ## 5. Interactions
88
+
89
+ - The existing repair driver is unaffected except that its clicks are now real pointer events and
90
+ fills read back; the existing driver unit tests (15) and the real-Chrome integration test pass.
91
+ - In passkey mode, `Runtime.evaluate` and clicks target the MAIN page session; popups are prepared and
92
+ resumed but not driven (no spec step drives a popup).
93
+ - `close()` clears the in-memory credential first, then closes the browser.
94
+
95
+ ## 6. External surfaces
96
+
97
+ None: no route, no notice, no config. Chrome launch flags change only in passkey mode.
98
+
99
+ ## 6b. Operator-surface quality (Operator-Surface Quality standard)
100
+
101
+ No operator surface.
102
+
103
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
104
+
105
+ A browser session is machine-local by nature (it drives this machine's Chrome and profile). The
106
+ credential material it holds in memory comes from the machine-local store (Increment 2) and never
107
+ leaves the process. No state is written by this increment.
108
+
109
+ ## 8. Rollback cost
110
+
111
+ Pure code. Reverting restores the TCP-only browser and script clicks. No persistent state.
112
+
113
+ ---
114
+
115
+ ## Conclusion
116
+
117
+ The browser can now hold a passkey safely: attached before any navigation, removed before any
118
+ document request that could reach another relying-party origin, and driven with real input. Every
119
+ spec-named case is covered by a real-Chrome test against the fixture, and the three load-bearing
120
+ behaviours were mutation-checked (removal, add-time origin check, popup loading). Clear to ship after
121
+ the second-pass review.
122
+
123
+ ---
124
+
125
+ ## Second-pass review (if required)
126
+
127
+ **Reviewer:** independent subagent (Claude), 2026-09-22 20:33 PDT — **Concern raised**, then fixed
128
+ and re-verified (round 2 below).
129
+
130
+ Round 1 findings (verbatim substance):
131
+
132
+ 1. **`addCredential` ignored other page targets' origins.** The origin walk covered only the main
133
+ target's tree plus iframe targets; popups (`type:'page'`) were never inspected, yet the add then
134
+ loaded the credential into EVERY prepared authenticator. A popup that had earlier moved to
135
+ `myaccount.google.com` (removal fired correctly then) would be re-armed by a later add on the
136
+ holder-origin main page. Spec §3.1 is per-target.
137
+ 2. **Removal was gated on an in-memory flag, and prepare/remove could interleave.** The sweep skipped
138
+ sessions without an `authenticatorId`; a popup mid-`prepareSession` could be skipped, then read
139
+ `this.credential` before the null write and load it. That target then held the key while
140
+ `hasCredential()` was false, and the request-time hook's flag check short-circuited — its own
141
+ forbidden first navigation would be continued WITHOUT removal.
142
+ 3. Minor: `topLevel` fell OPEN when `Page.getFrameTree` had failed (a top-level move to a non-RP
143
+ origin kept the key); `sawNavigationBeforeAttach` is a weak diagnostic, not the spec's proof;
144
+ `runInPage`/`clickSelector` were public arbitrary-evaluate surfaces restricted only by a comment.
145
+
146
+ Confirmed OK by the reviewer: `continueRequest` strictly after `removeCredential` resolves,
147
+ timeout/non-"gone" errors → `failRequest`; pipe framing/close handling; real clicks in both modes
148
+ with the `form.requestSubmit()` fallback and read-back gate; the mutation claims are credible against
149
+ the listed tests; no material is logged (`exportCredentials` is the only egress).
150
+
151
+ **Fixes (all in `src/core/ChromeCdpReloginBrowser.ts`):**
152
+
153
+ - (1) `addCredential` now walks EVERY page target: the main target must satisfy `mayAddCredential`;
154
+ every secondary target (popup) must have no frame on another RP-family origin
155
+ (`mayAddSecondaryTarget` — blank/paused, holder-origin, or outside the RP family all pass). A popup
156
+ on another RP-family origin vetoes the add with `passkey-origin-not-allowed`.
157
+ - (2) One credential lock (`withCredentialLock`) serialises add, remove and the prepare-time load; in
158
+ `prepareSession` the authenticator becomes visible to removal AND receives the credential in the
159
+ same locked step, so a concurrent removal runs entirely before (credential null ⇒ nothing loaded)
160
+ or entirely after (the target is in its sweep). Removal no longer trusts the flag: it sweeps every
161
+ id this browser EVER loaded (`knownCredentialIds`) from every authenticator, and the request-time
162
+ hook runs whenever any id is known.
163
+ - (3) Unknown frame identity (no session record / no frame tree) is treated as TOP-LEVEL — the
164
+ stricter rule; over-remove, never under-remove. `runInPage` / `clickSelector` are refused under the
165
+ production Google policy (`*-fixture-only`), so the arbitrary-evaluate surface exists only against
166
+ the local fixture. `sawNavigationBeforeAttach` is documented as a diagnostic.
167
+
168
+ **Evidence added:** `tests/unit/chrome-cdp-passkey-credential-race.test.ts` (7 tests against a
169
+ scripted CDP `send`, no Chrome): the exact skip-mid-prepare interleave (a later session keeps the
170
+ sweep busy while the popup finishes preparing) → no authenticator holds the key; a popup attached
171
+ before the removal is swept; a popup on another RP-family origin vetoes the re-add and stops vetoing
172
+ once back on the holder origin; an unrelated-origin popup does not veto; unknown frame identity
173
+ removes on a non-RP top-level move; a known subframe on an unrelated origin keeps the key; the
174
+ fixture-only refusal under the Google policy. Mutation checks: with the lock removed the interleave
175
+ test fails (`[0,0,1]`); with the secondary-target veto removed the popup-veto test fails; with the
176
+ fail-open `topLevel` restored the unknown-identity test fails. Real-Chrome: a new fixture test opens
177
+ a popup, moves it (by window name) to the other RP-family origin, sees `[0,0]`, gets the re-add
178
+ refused, moves it back, and sees `[1,1]`.
179
+
180
+ **Round 2:** the reviewer's two gaps are closed by structure (per-target check; lock + by-id sweep),
181
+ each with a test that fails when its fix is removed. Concur.
182
+
183
+ ---
184
+
185
+ ## Evidence pointers
186
+
187
+ - `tests/unit/passkey-browser-policy.test.ts` — 20 tests, production and fixture policies.
188
+ - `tests/unit/chrome-cdp-passkey-credential-race.test.ts` — 7 scripted-CDP tests for the second-pass
189
+ findings (interleave, popup veto, unknown frame identity, fixture-only surfaces).
190
+ - `tests/integration/passkey-browser-fixture.test.ts` — 11 real-Chrome tests: pipe transport, mint →
191
+ export → re-inject → assert (portability), add refused off the holder origin, removal on top-level
192
+ move / 302 redirect / RP-family iframe, unrelated iframe keeps it, popup auto-attach with both
193
+ authenticators loaded, popup-on-other-origin vetoes a re-add until it returns, identity read, real
194
+ clicks. Mutation checks: with request-time removal
195
+ disabled the three removal tests fail; with the add-time origin check disabled the refusal test
196
+ fails; with new-target loading disabled the popup test fails.
197
+ - `tests/integration/chrome-cdp-relogin-browser.test.ts` (existing, real Chrome, TCP mode) and
198
+ `tests/unit/anthropic-relogin-browser-driver.test.ts` pass unchanged.
199
+
200
+ ---
201
+
202
+ ## Class-Closure Declaration (display-only mirror)
203
+
204
+ - `unbounded-self-action` — closure: **n/a** — reason: one-shot user-driven action, not a
205
+ self-triggered loop: `ChromeCdpReloginBrowser.open()` spawns exactly one Chrome process per
206
+ operator-approved repair episode (the orchestrator bounds attempts); the browser never restarts,
207
+ retries or respawns itself.
208
+ - No agent-authored-artifact defect — not applicable. New capability from an approved spec; no
209
+ self-triggered controller is added.
210
+
211
+ ## Post-review follow-up (CI ratchet)
212
+
213
+ - `no-silent-fallbacks` counted the two pure-parse catches in `PasskeyBrowserPolicy` (`originOf` → null,
214
+ `isRpFamilyOrigin` → false). Neither hides a decision: null/false are the STRICT branches (removal on an
215
+ unparseable URL; "not in the family" on an unparseable origin). Annotated `@silent-fallback-ok` with that
216
+ reason rather than raising the baseline. No behaviour change.