feat(browser): attach recorder to existing sessions - #364
Conversation
cd51a69 to
ec04f25
Compare
Second independent review: DO NOT MERGEReviewed at exact head P1 (blocker) — short secret prefixes become sticky scrub values
1. Silent identity degradation, all modes. Secret
2. Attach mode aborts with a false diagnosis. 3. Corrupted URL evidence on disk (launched mode): Why CI is green: every live secret in P2 — trusted field-label cache leaks another declared secret
P2 — declared secret survives a same-origin GET form submitThe init script builds a fresh closure per document ( P3 — the CLI asserts the opposite
P3 —
|
ec04f25 to
e82ef2e
Compare
Re-review at
|
| prior finding | status |
|---|---|
| P1 keystroke prefixes corrupt identity | PARTLY CLOSED — corruption gone in the ordinary shape; new P1 leak introduced |
| P2 attach-mode false abort | CLOSED |
| P2 label cache leaked another secret | CLOSED |
| P2 secret survives GET form submit | CLOSED by withholding, verified end to end |
| P3 CLI asserted the opposite | reworded, but the new wording is false under the new P1 |
P3 meta.json mutated after publish |
CLOSED — stamp precedes _promote_recording() |
Test evidence
70 passed / 1 failed at head; the single failure is the live CDP campaign timing out in start(), matching the known environment limit. The 9 new or changed cases are not vacuous — all 9 fail against parent ec04f25 and pass at c8f2aa5, confirmed by running the head test file against parent source.
Blockers
retainedSecretValuesmust never drop a value currently in the field, and must not treat an earlier committed value as a prefix. Only a value that a later value continues and that was never itself a commit point is a keystroke prefix.- Give a declared secret field a stable key when it has no
nameand noid; add a regression case for an unnamed swapping password field. - Mark and count a withheld accessible name and
sidas a withheld selector is marked — or weaken the "never disarms silently" claim.
On the fail-closed evidence cost
Right trade in principle — the alternative retains a value Flow cannot scrub — but as built it will hurt operators: one short ambiguous value withholds unrelated evidence for the rest of the recording, and blocker 2 makes that the default for an ordinary unnamed password field.
🤖 Generated with Claude Code
c8f2aa5 to
7acd716
Compare
Round 4 at
|
Round-three review at
|
| evidence | c8f2aa5 |
7acd716 |
|---|---|---|
url |
http://host.test/reports/invoice/summary |
http://host.test/reports/[secret]/summary |
title |
Quarterly invoice summary |
Quarterly [secret] summary |
sid (clicked row) |
MRN 44120 invoice Alice Example |
MRN 44120 [secret] Alice Example |
sid_withheld |
null | null |
structural.selector |
#save-invoice-report |
null |
structural.name |
Save the invoice report |
Save the [secret] report |
The DOM identity tier disarms and the identity text is rewritten. The row identity sid is the record-identity evidence, rewritten with no marker — because a rewrite is not a withhold. Replay then compares against text the page never held. For a 33-char value the closure retains 28 values, 3 needles each, growing per keystroke.
The trigger is ordinary: passwords commonly start with a dictionary word, an application name, or an organisation name. The comment at :545-548 states the correct rule; the code does the opposite.
Finding 2 — P1. A swapping controlled input leaks the plaintext ladder.
interactive_recorder.py:616-647, superseded marks at :1073, :1227, :1383. The recorder listens in the capture phase, so it emits before the page's oninput updates title/URL — every reflecting page hands Flow a stale reflection, which is exactly what observedSecretValues exists to cover. For a controlled input that swaps its node, every earlier node is superseded and disconnected; :622-624 send all its observed values to the droppable bucket and the successor's longer value continues each one, so Flow drops them all.
Reproduced, secret hunter2x:
swap + reflect url = ".../charts/hunter" title = "session for hunter"
url = ".../charts/hunter2" title = "session for hunter2"
plain + reflect url = ".../charts/[secret]" title = "session for [secret]"
The swapping page writes 7 of 8 characters into evidence in plaintext. The PR body's "A stale reflection is still redacted", marked verified, is false for the superseded path — the path the mechanism exists to serve. No test combines a swap with a reflection: ...swaps_its_node does not reflect, ...still_holds_after_a_commit does not swap. Present at c8f2aa5 as well, so round three did not introduce it — but the PR does, since main at 068b777 has none of this machinery.
Finding 3 — P3. The code contradicts its own contract.
:605-613 vs :628. The docstring says a value is dropped only when "no commit point ever recorded it", but :628 puts committedValuesFor(el) into the droppable bucket whenever the element is superseded. Fix the code or the comment.
Attacks that found nothing
Round two repros (b) and (c) clean; two elements sharing one field key clean; unproven focus transfer clean; markers do not over-report; no path yields a null selector and null name with no marker; session inheritance across a swap always reaches the refusal.
Verdict
DO NOT MERGE. Suggested directions: for Finding 1, bind a retained earlier value to the moment it existed and stop using it once the field moves on, rather than keeping it for the whole recording. For Finding 2, do not drop a superseded value that Flow sampled while evidence was being emitted.
Confidence: the mechanism is not converging. Each of three rounds closed the named defect and introduced a new one in the same retention rule; this round both re-created round one's evidence corruption and left the swap-plus-reflection leak untested. Recommend reconsidering the approach — scrub-after-the-fact by string matching over evidence text — rather than a fourth patch.
🤖 Generated with Claude Code
Round 4 at
|
| protection | state |
|---|---|
| Ambiguous multi-node replacement refusal | PRESENT |
input[type=password] auto-detection |
PRESENT |
Stickiness via attributeOldValue |
PRESENT |
| iframe refusal | PRESENT |
| Popup / new-tab refusal | PRESENT |
| Closed-shadow-root refusal | PRESENT |
| Atomic publication | PRESENT |
| Committed-value retention after the field is removed | GONE (P1-B) |
A load-bearing accident you should know about
On the launch path the init script runs before <title> parses, so preSecretTitle is always ''. Any titled page therefore withholds its URL and title as soon as a value exists. That accident, not the URL rule, blocked the first reproduction attempt. Moving the seeding point would silently remove this protection.
Tests
10 new tests, not 9. With a pinned PYTHONPATH: 10 failed at 7acd716, 10 passed at ff5d71e. Full file: 67 passed in 101 s. All nine deleted identifiers are absent from source, tests and docs.
Two earlier claims corrected
The live CDP attach campaign does run on this Mac: 83.17 s at load average 6.66, under a 300 s marker. Two earlier agents reported an environment limit; they were wrong, and it was contention.
The recorded URL and title never came from the capture-phase emit(). At 7acd716 no Python reads ev["url"]. That sample was dead evidence.
Minor
P3: the ambiguous-replacement refusal reports the wrong reason (JS sets one message at :1418, Python raises fixed text at :2279-2286). P3: no test exercises that guard. P3: _safe_page_label (:262-274) keeps the URL path in the multi-tab refusal printed to the terminal.
🤖 Generated with Claude Code
07372d5 to
d1a762e
Compare
Close the four reproduced source-time secret leaks the independent review found in the attach recorder, plus two adjacent races, and repair the finalization lifecycle latch that the fix exposed. Secret boundaries: - A page can replace a declared secret field before its first input event. Attribute records are now applied ahead of rewrite matching, and the removed node's sticky state transfers to its single proven replacement. - A field can lose its name/id while typing. Classification is sticky to the DOM element and its input session; an attribute change cannot remove secrecy. - A multi-node rewrite has no proven field mapping. Every candidate is masked AND its first input refuses the recording, instead of assigning one removed field's identity (and input session) to all of them. - A cached field label can reproduce the current secret value. Trusted labels are scrubbed against that field's live value. - ARIA textboxes read innerText/textContent, so their values enter the scrub set; printable keydowns on a bound secret field no longer emit hotkeys; native non-text controls no longer trigger a false refusal. Screenshot and CDP boundaries: - The closed-shadow inventory runs as a screenshot guard, so a root that appears during screenshot preparation is bound or refused before any bytes exist. Launched mode gets the same initial inventory. - The origin guard reads only location.origin instead of the full URL, so a reflected secret in the query string cannot cross into Python. - The privacy scan scopes its resolved nodes to one protocol object group and releases it after each scan. - Marker cleanup pierces open shadow roots, and a session-keyed cleanup handle survives a page that replaces the global recorder object. Finalization: - finish() runs the privacy guard BEFORE arming the finalization latch. The guard's page round-trips deliver lifecycle events Chromium queued during recording; arming first made an ordinary same-origin history.replaceState from the recording itself alias a post-evidence frame change and refuse every valid recording that reflects state. - _handle_frame_navigation refuses on the armed latch without another page evaluate, which would re-enter dispatch while finalizing. Tests: - Live Chromium regressions for pre-input replacement, same-task identity loss, ambiguous multi-node rewrite, label/value equality, ARIA textbox, AltGr keydown, static and late unbound closed shadow roots, and a replaced page privacy guard. - The five finalization tests use a fake privacy CDP session, since the guard now runs on the finish path. - The live trial sequence reactivates the app tab after each popup or new-tab refusal. Chromium throttles a backgrounded tab, which made the next trial's first evidence screenshot time out on a loaded runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…after rebase The claims gate from PR #371 emits ci_job for each cited evidence node, and the browser attach claim entry is new. Regenerate the derived report and the reviewed public artifact inventory so both match the registry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e prefixes
The page closure retained the value of a declared secret field on EVERY input
event, so it kept each intermediate keystroke prefix, then substring-replaced
every retained value across later URL, title, label, selector and structural
text -- iterating over the already-substituted string, so it also rewrote the
inside of its own `[secret]` placeholder.
An independent review reproduced three consequences in real Chromium:
1. Silent identity degradation in every mode. With `charlie1` typed into a
password field, a later click on `#chart-save` recorded
`{"selector": null, "name": "Save [se[secret]ret]t"}`. `uniqueSelector`
returned null whenever scrubbing altered an element id, so the DOM identity
tier disarmed for any element whose id/name/data-testid contained one
character of the prefix. Nothing refused; the bundle compiled and looked
healthy.
2. A false abort in attach mode. `emit` wrote the scrubbed `location.href` into
every event and the origin guard parsed that as the origin, so on
`http://host.test/app` with secret `hunter2` the first keystroke produced
`"[secret]ttp://[secret]ost.test/app"` and refused the whole recording with
"a browser event came from outside the declared application origin".
3. Corrupted URL evidence on disk in launched mode
(`"url_after": "h[secret][secret]p://127.0.0.1:65479/"`).
What changed:
- Redaction is bound to the ELEMENT, not to a growing value set. The closure
keeps the bound element and reads its CURRENT value at scrub time; it never
retains a keystroke prefix. Values are committed only where the field can no
longer take a keystroke (change, focusout, submit, pagehide), so a value
stays scrubbable after the page clears the field.
- Redaction makes one left-to-right pass over the original text and never
re-reads its own output, so a placeholder can no longer be rewritten.
- A URL is redacted one whole token at a time (path segment, query name, query
value, fragment). The scheme, host and port are never rewritten.
- `emit` sends `location.origin` beside the scrubbed URL and the origin guard
reads that, so redaction can never refuse a valid recording.
- A value too short to tell a real reflection from a coincidence FAILS CLOSED:
Flow withholds the whole URL token, title, label or structural text instead
of keeping a partially rewritten copy. A short secret still never persists.
- Identity refusal is visible, never a bare null selector: the action carries
`identity_withheld`, `meta.json` carries `identity_withheld_events`, and
`record` prints how many actions carry no DOM selector and why.
- The trusted field-label cache is scrubbed against every declared value, not
only the field's own value. Document order meant a field bound before another
declared field cached that other field's pre-filled literal, which reached
`events.jsonl` as a plain `field_label`.
- A declared value no longer survives a same-origin GET form submit. Each
document builds its own closure, so a document that does not hold the value
cannot scrub it: Flow withholds the URL and title for such a document, notes
it in `meta.json`, and reports it. A later document that DOES hold the value
scrubs itself and keeps its evidence.
- `meta.json` is stamped with the recorded surface BEFORE the atomic publish,
so a crash can no longer publish a surface-unbound recording; the CLI stamp
is now a no-op when the recorder already stamped it.
- The `record` secret line states what Flow actually guarantees.
Tests (all fail on the parent commit, pass here):
- `test_page_closure_keeps_url_and_identity_evidence_for_a_lowercase_secret`
- `test_page_closure_scrubs_a_cached_label_holding_another_declared_secret`
- `test_launched_recording_withholds_url_evidence_after_a_get_form_submit`
- `test_attached_recorder_reads_the_origin_the_page_reports`
- `test_structural_text_is_withheld_after_a_secret_leaves_its_document`
- `test_recording_privacy_notices_report_what_flow_withheld`
- `test_stamping_a_recorded_surface_does_not_rewrite_a_published_recording`
Trial 1 of the live CDP campaign now types a lowercase secret whose characters
occur in the attach URL. Every live secret in this file was an uppercase phrase
sharing no character with `http://127.0.0.1:<port>/`, and that blind spot hid
all three defects.
The browser claim no longer says the live campaign covers "URL/title
reflection" without qualification: both reflection cases use
`history.replaceState`, so the coverage is same-document.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A withheld selector must never read as an element that simply had no stable identity. Lock the reason field on the live case whose button id IS the declared secret value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…obally A controlled input that swaps its DOM node on every change made every commit point fire in the middle of typing: replacing the focused element fires focusout, so the closure committed `c`, `ch`, `cha`, … as if each were a declared value. Those short values then made every later scrub ambiguous, so the recorder withheld the URL, the title, and the DOM identity of unrelated elements for the rest of the recording — fail closed, but it destroyed evidence a correct recording should keep. Committed values are now filed under their DECLARED FIELD (falling back to the input session when a field has no declared name). At scrub time a value that another value of the SAME field continues is dropped: it is a keystroke prefix of that field's value, not a declared value of its own. The complete value is still redacted, and two declared fields never share a key, so one field's value can never suppress another's. Regression: `test_page_closure_keeps_evidence_when_a_secret_input_swaps_its_node` types a lowercase secret into a field that replaces its node on every keystroke, then clicks an unrelated button and requires the exact selector, the exact accessible name, and the exact URL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A commit point can fire in the middle of typing, because replacing a focused element fires focusout. Say so, and point at the per-field prefix rule that handles it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…corded The prefix rule from 5a871d4 inferred a keystroke prefix from string shape alone, so a longer COMMITTED value suppressed the field's own CURRENT value. An independent re-review reproduced the leak end to end: type `hunter2`, blur (which commits it), return and press Backspace once, and the live value `hunter` was dropped as a "prefix" and never redacted -- `"url_after": "http://127.0.0.1:54721/charts/hunter"`, `"title_after": "session for hunter"`, no withheld marker, and the CLI printing a promise that was false there. The parent commit corrupts that evidence but does not leak it, so the rule was strictly worse in that shape. Retention now tracks WHERE a value came from instead of guessing from its shape: - Committed values are filed under the ELEMENT that held them at the commit point, not under the field. `change` and `focusout` commit only the element they name, so replacing a focused node no longer commits the prefix that its successor happens to hold at that instant. - A value is droppable only when a controlled input REPLACED the node that holds it, the page no longer holds that node, and another value of the same field continues it. `supersededSecretElements` records that replacement where it is proven: the single-node mutation transfer, and the focus transfer to a replacement of a disconnected active element. - A value the field currently holds is never dropped. - Each element keeps the last value it held, and every earlier value long enough to identify. A page that reflects a field as the operator types writes the PREVIOUS value into the title or the URL, and that value must still be redacted. Two more findings from the same review: - A password field with no `name` and no `id` had no stable key: discovery derives a new input session per replacement, so a swapping node looked like a new declared field on every keystroke, no prefix was ever recognised, and every later scrub became ambiguous -- the "fail closed but useless" state. A replacement now inherits the state, and the input session, of the node it replaced, whether the mutation batch or a focus transfer proves the replacement. - A withheld accessible name, role, or clicked-row identity disarmed an identity check with no marker at all; only a withheld identity ATTRIBUTE was marked. `scrubIdentityText` now records the reason, `structuralTarget` reports it in `identity_withheld`, a withheld row identity travels as `sid_withheld`, and the recorder counts the action once for the operator. Also: `scrubSecretUrl` returns the URL the page reports when it redacted nothing, instead of a rebuilt URL with normalised percent-encoding. Regression tests (all four fail on c8f2aa5, pass here): - `test_page_closure_redacts_a_value_the_field_still_holds_after_a_commit` - `test_page_closure_keeps_evidence_when_an_unnamed_password_swaps_its_node` - `test_page_closure_marks_a_withheld_name_and_row_identity` - `test_withheld_row_identity_is_counted_for_the_operator` Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Say which values redaction keeps -- everything the field holds and everything a commit point recorded -- and which single case it drops. Extend the never-disarm-silently statement to the accessible name, the role, and the clicked-row identity, which now carry their reason too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lding Three independent reviews each found a different blocking defect in the recorder's secret-redaction mechanism, and each fix moved the defect into the same retention rule. The literature says why: removing a value from text that was already captured is a known-unsolved problem. Englehardt, Acar and Narayanan measured every major session-replay vendor in 2017 and found that none redacts displayed content automatically and that all of it leaked; PostHog and Sentry still carry open issues for secrets in replay URLs. Production tools solve it at capture time instead, with element-bound, deny-by-default masking. This replaces the retention rule with that model. One rule now governs every text the recorder produces: report it exactly, or withhold it and say why. There is no placeholder substitution left anywhere in the recorder. Root cause. `emit()` sampled `location.href` and `document.title` inside a CAPTURE-phase listener, before the page's own handlers ran, so the values it read described the state before the action. The value history existed only to repair that staleness, and the history produced both open P1s. Python never read those event fields: the recorded URL and title already came from `_read_scrubbed_page_state()` at the settled boundary. The events now carry no reflected text at all, and that boundary is the only sampling point. Deleted: `observedSecretValues`, `committedSecretValues`, `retainedByKey` and its superseded/prefix-drop ladder, `supersededSecretElements`, the `change`/`focusout`/`submit`/`pagehide` commit points, `redactSecretOccurrences` and every scrub-and-rewrite path (`scrubSecretText`, `scrubSecretUrl`, `redactUrlToken`, `SECRET_PLACEHOLDER`). Added: - `liveSecretValues()` -- values that CONNECTED bound elements hold at match time, read live. A node the page detached is not a source of values, which is what a controlled input leaves behind on every keystroke. - `identityTextOrNull()` -- identity evidence (selector, role, accessible name, clicked-row identity, receiving field name) is exact or withheld with a reason. Replay compares identity against the live page, so a rewrite would compare against text the page never held, invisibly. - `safePageState()` -- reflected evidence is reported only while it has not changed since before the document held any declared value, and so cannot be a reflection of it. Anything else is withheld whole: origin-only URL, empty title. The baseline is seeded at install, before the document can reflect anything typed into it. - Every distinct withholding reason reaches `meta.json` and the CLI notice. Regression tests. Each fails at 7acd716 and passes here: - a password beginning with a common word leaves the URL, title, row identity, accessible name and an unrelated button id exact (was: silently rewritten) - a node-swapping input that reflects as you type no longer leaks the prefix the field stopped holding (was: emitted verbatim) - identity holding a declared value is withheld and marked (was: rewritten with no marker, so replay would compare against text the page never showed) - no event carries a URL or a title - the three prior reproductions stay closed - the stated limit -- text that predates the value -- is pinned The ambiguous multi-node replacement refusal, `input[type=password]` auto-detection, `stateFromPriorDeclaration` re-binding, and the screenshot mask are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… update check_release_consistency.py pins a reviewed SHA-256 of every public artifact. The redaction redesign changed claims.yaml and its generated docs/verification.json, so those two hashes move. Reviewed: exactly those two paths change, none added, none removed. docs/VERIFICATION.md and docs/verification.json are regenerated with the timestamp string the validate-claims workflow uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When a document holds both a short declared value and a long one, and a piece of identity text contains both, the operator should be told the text held a declared value, not that it could have held one by chance. Either result withholds the text; only the reason changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bber Several comments described a mechanism this branch deleted: a scrubbed URL on the event, values retained at scrub time, and a later document 'scrubbing' text. Nothing is scrubbed any more -- text is reported exactly or withheld. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 4 judged the retention replacement sound and found three bounded defects, two of them pre-existing rather than regressions. It also found the whole-URL refusal too broad: a single-page application that routes after a login lost its URL evidence for the whole session. P1-A, pre-existing. A same-origin GET submit carries the declared value under the field's own NAME, because that is how an HTML form works. The results document seeded its baseline from the URL it loaded with -- which already held the value -- and Python stopped applying the cross-document rule as soon as that document held a declared value of its own, so the URL was reported. Closed by STRUCTURE, not by matching. The URL is now parsed instead of treated as one opaque string: * The origin and the PATH are reported. A path change is the single-page application case and is app structure, not operator input. This is the evidence the old refusal destroyed. * Every parameter NAME survives. The VALUE of a parameter whose name is a declared secret field name, or the name or id of any bound field (which includes every auto-detected input[type=password]), is dropped -- always, deterministically, with no reference to the value. Sentry and Datadog redact URLs the same way and for the same reason. * A parameter Flow cannot prove predates the moment the document first held a declared value loses only ITS value. The whole URL is not withheld for it. * Nothing is invented. A dropped value becomes empty. Flow removes characters; it never adds characters the page did not show. * A document born after some earlier document received a declared value has no trustworthy baseline, so it proves no parameter value. Python supplies that recording-wide fact, which the closure cannot know. The net, a DETECTION and never a rewrite: if the URL Flow is about to report still holds a value it can see, the whole URL is withheld and the operator is warned that the application put a declared secret into it -- an application defect that exists with or without Flow, as OWASP notes. The net runs on the path in both containment directions, so a page that writes the field into its path as the operator types cannot leave a segment behind. Matching is sound here and was not sound before: this function now runs only from Python at the settled boundary, so it needs no history. The title has no structure to exploit, so it keeps the unchanged-or-withhold rule plus the same net. P1-B, a regression against 7acd716. Deleting the commit points lost the ability to match a value after the page removes the field: an SPA wizard that replaces its form with a summary row leaked the value into the clicked row's identity. Commit-point retention is restored for ONE purpose -- deciding whether to WITHHOLD identity text -- and never for the URL, the title, or any rewrite. A spurious match can only withhold, which costs evidence and cannot corrupt or leak, so the rule that failed three reviews is not re-created. A commit is decided at the microtask checkpoint, so a controlled input that fires focusout on the node it just replaced commits no keystroke prefix. Across documents the same case is closed by name: a document recovers the value of any inbound query or fragment parameter whose NAME is declared, long enough to identify, and uses it to withhold identity text. P2-A, pre-existing. targetName() returned null for a bound secret field with no reason, so a declared field with an aria-label produced a silent null. It now states secret-field-name-not-read and is counted. Tests: nine new cases, each failing at ff5d71e and passing here. The four reviewer reproduction scripts all pass. tests/test_browser_attach.py: 74 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reflected-evidence contract changed from a whole-URL refusal to a structural reduction, so README, docs/BROWSER_RECORDING.md and claims.yaml no longer described what the code does. They now state: origin and path reported, parameter names kept, declared-parameter values dropped by name, unproven values dropped one at a time, nothing invented, and the detection net with its OWASP warning. The costs and the stated residual are written down beside them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fragment that names nothing -- `#section` rather than `#a=1` -- has no structure to reduce, so it was passed through unreduced and could carry a value the proof rule would have dropped from a named parameter. Treat the whole fragment as one unnamed value and apply the same proof. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ural Round 5 reproduced a declared secret reaching events.jsonl through the URL PATH. An ordinary REST redirect answers a GET submit with `/results/<value>`. The new document is a fresh closure: no bound element holds the value, nothing was committed there, and no parameter NAME identifies a path segment, so the detection net is empty. `requireProof` never applied to the path, and round 4 had removed the Python backstop, so the literal was reported while meta.json told the operator the URL was protected. Regression against ff5d71e. Restore the cross-document rule. Only the FIRST document to hold a declared value reports its own reflected text; every later document's URL and title are withheld. A document that receives a value of its own is NOT exempt, because holding a value says nothing about whether it loaded with an earlier document's value in its path. This does not cost the single-page-application evidence the structural rule recovered, and there is now a test for that claim rather than an argument: history.pushState and history.replaceState do not build a new document, so the closure that held the value is the closure being sampled, and its route change is still reported exactly. The rule bites only on a real navigation, which is where the leak lives. Cross-document URL evidence IS lost again, and the docs say so. Second reproduction, same document: a scanner input that writes the value into the path and CLEARS its own field inside its own `input` handler. Nothing in the DOM holds the value by the time Python samples, and `change`/`focusout` then fire on an empty field, so every value source was empty. The capture-phase `input` listener runs BEFORE the page's handler, which is the last moment the DOM still holds the value, so it now records the last non-empty value per bound element -- ONE value, REPLACED on every keystroke, never a ladder, never used to rewrite. It applies only when nothing in the document holds a value any more, so a live field's value still wins and a detached node's keystroke prefix never withholds unrelated evidence. This also closes the reviewer's P3, where the page removes the field rather than clearing it. Also, one call: identityRefusal used liveSecretValues() while identityTextOrNull and labelTextOrNull used identityMatchValues(), so a results document refused the accessible name and the row identity but emitted `#row-<value>`. All identity paths now use the same set, which can only withhold more. Value comparison is now case-insensitive. Upper-casing an identifier before showing it is normalisation, not an application-defined transform, and widening a withhold test cannot leak. A dropped parameter is recorded only for a URL Flow actually reports, so meta.json never names a parameter of a URL that was withheld whole. Two dead bindings removed. Tests: four new cases failing at 07372d5 and passing here, plus one that must pass at BOTH heads because it proves the SPA evidence was not lost. All twelve reviewer round-5 cases pass; six of them fail at 07372d5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a value Round 6 reproduced four P1 leaks to events.jsonl, all silent, all present at 07372d5 as well. A scanner input that writes the badge into the URL and then clears its own field inside its own `input` handler holds nothing at any moment Python samples, and two rules keyed off what the DOM holds at that instant. CAUSE A -- the last-value cache was consulted only when NOTHING in the document held a value, while committed values were unioned unconditionally. There was no reason for the asymmetry. The test is now per element: a bound element that holds nothing right now contributes its cached value. It must stay per element rather than unconditional. A controlled input that swaps its node on every keystroke leaves detached elements still reporting `c`, `ch`, `cha`; unioning those in withheld any text containing a one-letter match and nulled `#chart-save` in three existing cases. A detached element is added only when no connected bound field holds anything, which covers a page that REMOVED its field while excluding the swap trail. That alone did not close F3: the cache holds ONE value per element, and a second scan displaced the first badge while the first was still in the URL. A value the next one does not CONTINUE was not edited away by the operator -- the page took it and started the field over -- so it is promoted into the withhold-only committed set, which is not per element. While the operator types, each value continues the one before it, so no keystroke prefix can be promoted. It is checked at the next input event, not at a microtask checkpoint after the current one: a checkpoint runs BETWEEN listeners, so it observes the field before the page's own handler has cleared it. CAUSE B -- `documentHeldSecretValue` was derived from live values, so it stayed false forever for a consuming page. The title net sits inside that flag while the URL net does not, and `state.secret` never became true, so Python never learned the document had received a value. It is now armed in the capture-phase `input` handler, the moment the document provably held one, which is what the documentation already said: "once a declared secret field RECEIVES INPUT". Python had the same asymmetry: `_track_secret_document` added to `_secret_doc_ids` from the input event but set `_first_secret_doc_id` only from the settled read, and the cross-document rule keys off the second. Both markers now move together through `_mark_secret_document`. Also corrected the cache comment: it is literally the last `input` event's value, not "the value the operator stopped on". Tests: four new cases, each failing at d1a762e and passing here. All round-4 and round-5 reviewer files pass (31). Eleven of thirteen round-6 reviewer cases pass; two assert the pre-fix behaviour and contradict F3/F4 -- see the PR body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9d25e67 to
d7eabd1
Compare
What changed
--browser-cdp-endpointand--browser-page-urlto attach Flow's Playwright recorder to one existing local Chromium tabviewport_history,viewport_before, andviewport_after, and validate each event against its exact PNG during compilationWhy
Users need to record workflows after SSO or 2FA without duplicating browser sessions. The prior launched-browser-only path could not reuse that state. A fixed-viewport restriction would also reject normal tab resizing and multi-monitor movement.
The implementation uses CSS-pixel screenshots and per-event viewport evidence. An idle geometry change is safe after a new stable frame is bound. An action during the transition remains a fail-closed refusal.
Safety and privacy
Source-time secret boundary (independent review round 2)
An independent reviewer reproduced plaintext secret capture in real Chromium
and found three adjacent races. Each now has code and a live regression case:
reordered-secretcase intest_page_closure_scrubs_replaced_prefilled_and_reflected_secrets;type_and_click_replaced_secretin the live CDP campaignname/idwhile typingprefilled-secretcase (identity removed before the first input)test_backend_runs_privacy_guard_before_screenshot_bytes_exist,test_launched_browser_refuses_static_unbound_closed_shadow_password, and the late-unbound-closed-root live caseambiguous-secretcaseAlso closed in this round:
label-equals-secret)location.origin, so a secret reflected into the query string cannot cross into PythonFinalization lifecycle repair
Arming the finalization latch before the privacy guard made an ordinary
same-origin
history.replaceStatefrom the recording itself alias apost-evidence frame change, which refused every valid recording that reflects
state into the URL.
finish()now runs the privacy guard first — its pageround-trips deliver the lifecycle events Chromium queued during recording — and
arms the latch only for the operations that retain final evidence.
The live campaign also reactivates the app tab after each popup or new-tab
refusal trial. Chromium throttles a backgrounded tab, which made the next
trial's first evidence screenshot time out on a loaded runner. That was the
testjob failure on the previous head.Source-time secret boundary (independent review round 3)
Round 3 reproduced a defect in the round-2 redaction itself. The page closure
retained the value of a declared secret field on EVERY input event, so it kept
each keystroke prefix, then substring-replaced every retained value across
later URL, title, label, selector and structural text — iterating over the
already-substituted string, so it also rewrote the inside of its own
[secret]placeholder.#chart-saverecorded{"selector": null, "name": "Save [se[secret]ret]t"}, and the DOM identity tier disarmed silentlychange/focusout/submit/pagehide; one left-to-right pass over the original text never re-reads its own outputtest_page_closure_keeps_url_and_identity_evidence_for_a_lowercase_secrethunter2onhttp://host.test/appevery emitted URL became"[secret]ttp://[secret]ost.test/app", so the first keystroke refused the recording as cross-originemitsendslocation.originbeside the scrubbed URL and the origin guard reads that; a URL is redacted one whole token at a time and the scheme, host and port are never rewrittentest_attached_recorder_reads_the_origin_the_page_reportsplus the URL assertions in the lowercase case"url_after": "h[secret][secret]p://127.0.0.1:65479/")field_labeltest_page_closure_scrubs_a_cached_label_holding_another_declared_secreturl_after/title_afterstructural_text_withheldinmeta.json, and reports ittest_launched_recording_withholds_url_evidence_after_a_get_form_submit,test_structural_text_is_withheld_after_a_secret_leaves_its_documentmeta.jsonwas mutated AFTER the atomic publish, so a crash could publish a surface-unbound recordingtest_stamping_a_recorded_surface_does_not_rewrite_a_published_recordingrecordprints what it withheld and whytest_recording_privacy_notices_report_what_flow_withheldTwo rules keep the fix from trading one failure for another:
reflection from an ordinary coincidence is never skipped and never partially
rewritten. Flow withholds the whole URL token, title, label, or structural
text instead. A 4-digit PIN still never persists.
data-testid,data-testornameholds a declared value, the action carriesidentity_withheld,meta.jsoncarriesidentity_withheld_events, andrecordprints how many actions carry no DOM selector and why.The test blind spot is closed. Every live secret in
test_browser_attach.pywas an uppercase phrase sharing no character with
http://127.0.0.1:<port>/,which is why three defects passed 46 tests. The new cases use lowercase secrets
(
charlie1,hunter2-…) whose characters occur in the URL, the title and thebutton id, and trial 1 of the live CDP campaign now types one too.
Claim wording corrected. Both reflection tests use
history.replaceState,so the live campaign's coverage of URL/title reflection is same-document. The
claim now says so, and the two new live cases are cited with what they prove.
One more defect the fix itself exposed: a controlled input that swaps its DOM
node on every change fires
focusoutin the middle of typing, so the commitpoints recorded
c,ch,cha, … as declared values, and every later scrubbecame ambiguous — evidence withheld everywhere, fail closed but useless.
Committed values are now filed under their DECLARED FIELD, and a value that
another value of the same field continues is dropped as a keystroke prefix.
Two declared fields never share a key, so one field's value can never suppress
another's, and the complete value is still redacted
(
test_page_closure_keeps_evidence_when_a_secret_input_swaps_its_node).Independent re-review round 4 — three blockers closed
The round-3 prefix rule inferred a keystroke prefix from string shape alone, so
a longer COMMITTED value suppressed the field's own CURRENT value. Retention
now tracks where a value came from.
hunter2, blur (which commits it), return and press Backspace: the live valuehunterwas dropped as a "prefix" and never redacted —"url_after": ".../charts/hunter","title_after": "session for hunter", no marker, and a CLI promise that was false therechange/focusoutcommit only the element they name and a node swap no longer commits its successor's prefix. A value is droppable only where a controlled input REPLACED the node holding it (supersededSecretElements, set where the replacement is proven) and the same field went on to hold a value that continues ittest_page_closure_redacts_a_value_the_field_still_holds_after_a_commit(round 5 renamed ittest_page_closure_withholds_reflected_text_the_field_no_longer_matches: same scenario, now closed by withholding)nameand noidhad no stable key: discovery derives a new input session per replacement, so a swapping node looked like a new declared field per keystroke and every later scrub became ambiguoustest_page_closure_keeps_evidence_when_an_unnamed_password_swaps_its_nodescrubIdentityTextrecords the reason,structuralTargetreports it inidentity_withheld, a withheld row identity travels assid_withheld, and the recorder counts the action oncetest_page_closure_marks_a_withheld_name_and_row_identity,test_withheld_row_identity_is_counted_for_the_operatorTwo things the fix also had to keep true, both verified:
A stale reflection is still redacted.This claim was false.Round 5 reproduced both halves of it: keeping "every earlier value long
enough to identify" retained the word
invoiceout ofinvoice-2026-quarterly-passphraseand then silently rewrote unrelatedidentity evidence with it, and a page that STOPS reflecting still showed a
value the retention rule had dropped as a prefix. Nothing is retained now.
See round 5 below.
unchanged; a replacement discovery bound in the same batch is tracked
separately (
batchDiscoveredSecrets) so it can still inherit itspredecessor's session.
Accepted correction to the round-3 PR text: "two declared fields never share a
key" was inexact —
name="x"on one element andid="x"on another are thesame declared field
xto the operator and share the key. Two differentdeclared NAMES never share one.
Also taken:
scrubSecretUrlreturns the URL the page reports when it redactednothing, instead of a rebuilt URL with normalised percent-encoding.
Independent re-review round 5 — the mechanism is replaced, not patched
Three rounds each found a different blocking defect, and each fix moved the
defect into the same retention rule. The founder asked for a literature review
before a fourth patch. It changed the plan.
What the literature says. Englehardt, Acar and Narayanan ("No boundaries",
Princeton 2017) tested every major session-replay vendor: none redacts
displayed content automatically, and all displayed content leaked. PostHog and
Sentry both still carry open issues for secrets in replay URLs, and PostHog
states it has no built-in way. Removing a value from text that was already
captured, by matching that text, is a known-unsolved problem. The industry
answer is capture-time, element-bound, deny-by-default masking: rrweb makes
password inputs structurally unmaskable, Sentry masks all text client-side.
So this round stops trying. One rule now governs every text the recorder
produces: report it exactly, or withhold it and say why. Flow never rewrites
captured text. The recorder defines no placeholder constant and writes no
placeholder into any captured text.
Root cause of all three rounds.
emit()sampledlocation.hrefanddocument.titleinside a CAPTURE-phase listener(
addEventListener(type, handler, true)), so it read them before the page'sown
oninputhad run: the values described the state BEFORE the action. Thevalue history existed only to repair that staleness. Python never read those
event fields — the recorded URL and title already came from
_read_scrubbed_page_state(), which the recorder calls at the settledboundary, the same boundary that captures the after-frame. Events now carry no
reflected text at all, and that boundary is the only sampling point.
invoice-2026-quarterly-passphrase) made the wordinvoicea retained value for the rest of the recording. A clicked row's identityMRN 44120 invoice Alice Examplewas rewritten toMRN 44120 [secret] Alice Examplewith NO marker, so replay would compare against text the page never showed; the URL, the title, the accessible name and an unrelated button id were rewritten or nulled tootest_page_closure_keeps_all_evidence_for_a_password_starting_with_a_wordcharlie-after the field had moved on tocharlie-alpha, and the retention rule droppedcharlie-as a superseded prefix, so it reached the recording verbatimtest_page_closure_withholds_a_stale_reflection_from_a_swapping_inputtest_page_closure_withholds_identity_that_holds_a_declared_valueDeleted.
observedSecretValues,committedSecretValues,retainedByKeyand its superseded/prefix-drop ladder,
supersededSecretElements, thechange/focusout/submit/pagehidecommit points,redactSecretOccurrencesand every scrub-and-rewrite path (
scrubSecretText,scrubSecretUrl,redactUrlToken,SECRET_PLACEHOLDER).interactive_recorder.pyis347 lines removed against 313 added.
Kept, unchanged.
input[type=password]auto-detection beside declared--secretfields;attributeOldValuehandling andstateFromPriorDeclarationre-binding an element that USED to carry a declared name; the ambiguous
multi-node replacement refusal; the screenshot mask; the origin sent as its own
field beside each event, so the origin guard never parses evidence text.
What fail-closed costs, stated plainly. A page that changes its URL or
title after a declared field has held a value reports an origin-only URL and an
empty title for the rest of that document, whether or not the change had
anything to do with the value. A single-page application that routes after a
password entry is the common case. Identity evidence, action coordinates and
the before/after frames are unaffected, so replay keeps its strongest identity
tiers.
One narrow reduction against
7acd716, deliberate. If an operator opens aURL that ALREADY contains the password and then types it into a declared field,
7acd716rewrote that URL to?token=[secret]; this head reports it. The URLpredates the value, so it passes the proof. Checking the live value against
unchanged text instead would withhold on a chance match with a keystroke
prefix — which is exactly P1 above: a password beginning with an ordinary word
would withhold the URL of every page whose text contains that word. Flow
protects a declared value from the moment a bound field holds it; text and
pixels captured before then are ordinary recording evidence. The limit is
documented in
docs/BROWSER_RECORDING.mdand pinned bytest_page_closure_states_its_limit_for_text_that_predates_the_value.Independent re-review round 6 — three bounded blockers, and URLs by structure
Round 4 of the external review judged the retention replacement SOUND and the
right call. It found three bounded defects, two of them pre-existing rather
than regressions, and separately established that the whole-URL refusal was too
broad. This round fixes exactly those. No redesign.
P1-A — a same-origin GET submit kept the full URL. Pre-existing.
The results document seeded its baseline from the URL it loaded with, which
already carried the value, and Python stopped applying the cross-document rule
as soon as that document held a declared value of its own.
7acd716leaked thesame way.
Closed by structure, not by matching. A URL is now parsed instead of being
treated as one opaque string:
application case, and a path is app structure rather than operator input.
key=valuefragment.--secret FIELDname, or the
name/idof any bound field (which includes everyauto-detected
input[type=password]). Deterministic, and it never looks atthe value. A same-origin GET submit carries a field under its own name, so
the leaking channel closes by name. Sentry and Datadog redact URLs the same
way and for the same reason.
declared value loses only ITS value. The whole URL is not withheld for it.
characters from a URL; it never adds characters the page did not show. That
keeps the "never rewrite" guarantee exact: there is still no value-matched
substitution anywhere, and identity text remains strictly exact-or-withheld.
trustworthy baseline, so it proves no parameter value. Python supplies that
recording-wide fact, which a fresh closure cannot know.
The net, a DETECTION and never a rewrite. If the URL Flow is about to
report still holds a value it can see, Flow withholds the whole URL, marks it,
and warns the operator that the application put a declared secret into its own
URL — a defect OWASP notes is already exposed through browser history, server
logs, proxies, CDNs and
Refererheaders, with or without Flow. The net checksa path segment in BOTH containment directions, because a page that writes the
field into its path as the operator types leaves a segment the current value
contains but no longer equals. Matching is sound here and was not sound in
rounds 1–3: this check runs only from Python at the settled boundary, so it
needs no history, and a match only withholds.
The title has no structure to exploit, so it keeps the unchanged-or-withhold
rule plus the same net.
P1-B — the deleted commit points lost a protection. REGRESSION.
An SPA wizard removes its declared field and renders the value into a summary
row; clicking the row recorded
"structured_identity": "Tokenhunter2-token-value".Commit-point retention is restored for ONE purpose — deciding whether to
withhold identity text — and never for the URL, the title, or any rewrite.
A spurious match can only withhold, which costs evidence and cannot corrupt or
leak, so the rule that failed three reviews is not re-created. A commit is
decided at the microtask checkpoint, so a controlled input that fires
focusouton the node it just replaced commits no keystroke prefix; withoutthat, prefixes such as
candchwithheld unrelated identity on every chancematch.
Across documents the same case closes by name: a document recovers the value of
any inbound query or fragment parameter whose NAME is declared, long enough to
identify, and uses it to withhold identity text there. That is what catches a
results page printing the value into a row.
P2-A — the last silent null. Pre-existing.
targetName()returnednullfor a bound secret field with no reason, so adeclared field with an
aria-labelproduced a silent null while a controlfield beside it returned its name. It now reports
identity_withheld: secret-field-name-not-readand is counted.The load-bearing accident, replaced with an explicit rule
The reviewer noted that on the launch path the init script runs before
<title>parses, sopreSecretTitlewas''and any titled page withheldboth URL and title as soon as a value existed. That is no longer what carries
the URL: URL and title are now decided independently, the URL by structure. The
''seed still applies to the title, and the seeding comment says soexplicitly —
safePageStaterefreshes both baselines at every sample takenwhile no declared value is held, so an ordinary page reaches its real title
before the operator types, and a document that ALREADY holds a value at install
keeps the
''baseline and withholds its title. That is the fail-closed side,stated rather than accidental.
Reviewer reproduction scripts
7acd716ff5d71etest_r4_repro.py(results page pre-fills)test_r4_repro2.py(results page does not pre-fill)test_r4_repro3.py::test_a(SPA wizard summary row)test_r4_repro3.py::test_b(new-document row)test_r4_repro4.py(silent null accessible name)Run unedited, with
PYTHONPATHpinned to each head and the import pathasserted before each run.
New regression tests
Nine, each FAILING at
ff5d71eand passing here, verified with a pinnedPYTHONPATHin a separate worktree:test_launched_recording_drops_a_declared_url_parameter_after_a_get_submittest_page_closure_withholds_identity_from_an_inbound_declared_parametertest_page_closure_withholds_identity_after_the_field_is_removedtest_page_closure_marks_a_withheld_secret_field_nametest_page_closure_reports_a_single_page_app_route_changetest_page_closure_drops_only_the_unproven_parameter_valuetest_page_closure_withholds_a_url_that_holds_the_value_in_its_pathtest_page_closure_warns_when_the_application_puts_a_secret_in_its_urltest_page_closure_states_its_debounce_limitThe four expected outcomes the design called for are the middle five rows.
What is still lost, stated plainly
declared field has held a value, and for every document after that one. A
title has no structure to reduce.
when it is unrelated to it. Only that value; the name, path and other
parameters stay exact.
including by coincidence. Fail-safe, and counted in
identity_withheld_events.URL on a timer longer than the settle window, or that writes a TRANSFORM of
the value rather than the value, shows text no value Flow can see contains.
The net will not match it, and Flow does not keep a previous value to catch
it. Pinned by
test_page_closure_states_its_debounce_limitand written intodocs/BROWSER_RECORDING.md.The optional P3s (the ambiguous-replacement refusal reporting Python's fixed
text rather than the JS reason, no test for that guard, and
_safe_page_labelkeeping the URL path in a terminal refusal) are NOT addressed in this round.
Independent re-review round 7 — the URL PATH channel
Round 5 of the external review reproduced a declared secret reaching
events.jsonlthrough the URL path. It also verified both deviations fromthe previous round's instructions as sound — the wider detection net only ever
withholds and no committed or inbound value crosses into Python, and the
microtask checkpoint closes windows without opening any. Both are kept.
The defect
"Report the origin and the PATH" gave the path no proof rule: only parameter
values and the bare fragment got
requireProof. The path's only protection wasthe value-matching net, and that net is EMPTY in a fresh document — no bound
element holds anything, nothing was committed there, and
inboundSecretParameterValuesreads the query and the fragment but never thepathname. Round 4 had also removed the Python backstop, which now only blanked
the title.
An ordinary REST redirect is enough. A GET form submit, answered with a 302 to
/results/<value>:A regression, and worse than a plain leak:
meta.jsonsaidapplication_placed_secret_in_urlandstructural_text_withheld, so theoperator was told the URL was protected while the literal sat in the file.
The fix
Only the FIRST document to hold a declared value reports its own reflected
text. Every later document's URL and title are withheld. A document that
receives a declared value of its own is NOT exempt: holding a value says
nothing about whether it loaded with an earlier document's value in its path.
This does not cost the single-page-application evidence, and there is now a
test for that claim rather than an argument for it.
history.pushStateandhistory.replaceStatedo not build a new document, so the closure that heldthe value is the closure being sampled; its route change is reduced by
structure and reported exactly. The cross-document rule bites only on a real
navigation, which is precisely where the leak lives.
test_page_closure_still_reports_a_same_document_route_after_the_fixandtest_a_same_document_route_is_never_treated_as_a_later_documentpin it, andthe first of those passes at BOTH heads by design — it is a did-not-regress
guard, not a defect proof.
The second reproduction, same document
A scanner input that writes the value into the path and then clears its own
field inside its own
inputhandler. By the time Python samples, nothing inthe DOM holds the value, and
change/focusoutthen fire on an empty field,so every value source was empty and no warning fired at all.
The capture-phase
inputlistener runs BEFORE the page's handler — the lastmoment the DOM still holds the value. It now records the last non-empty value
per bound element: ONE value, REPLACED on every keystroke, never a ladder,
never used to rewrite. It applies only when nothing in the document holds a
value any more, so a live field's value still wins and a detached node's
keystroke prefix can never withhold unrelated evidence. That also closes the
reviewer's optional P3, where the page removes the field rather than clearing
it.
Also fixed
identityRefusalusedliveSecretValues()whileidentityTextOrNullandlabelTextOrNullusedidentityMatchValues(), so a results document refusedthe accessible name and the row identity but emitted
#row-<value>. Allidentity paths now use the same set. Widening can only withhold more.
it is normalisation, not an application-defined transform. This closes the
reviewer's attack case 4.
meta.jsonnever names a parameter of a URL that was withheld whole.names,reason_key) are removed.Reviewer round-5 reproductions
All twelve cases across the five files pass. With
PYTHONPATHpinned:07372d5test_r5_e2e.pytest_r5_e2e2.py(same-document, field cleared)test_r5_e2e3.py(REST redirect)test_r5_e2e4.py(selector from a row id)test_r5_attack.py(6 cases)The round-4 reproductions (
test_r4_repro*.py) still pass: 5 passed.New regression tests
07372d5test_launched_recording_withholds_a_redirect_that_puts_the_value_in_a_pathtest_launched_recording_withholds_a_later_document_url_after_a_get_submittest_a_same_document_route_is_never_treated_as_a_later_documenttest_page_closure_withholds_a_selector_built_from_an_inbound_valuetest_page_closure_still_reports_a_same_document_route_after_the_fixWhat this costs, stated plainly
Yes — evidence the structural rule recovered is lost again, and only for
cross-document navigation. Once a declared value is typed, every later
document reports an origin-only URL and an empty title, exactly as at
ff5d71e. What survives is the same-document case: an SPA route change, ahistory.replaceState, and a query change inside the document that held thevalue are still reported exactly, with declared-parameter values dropped by
name. That was the rationale for the structural rule and it is intact; the
cross-document half of it was never sound, because a path segment has no
parameter name to identify it.
Everything else that was already stated stands: a title withheld for the rest
of a document once it changes; an unproven parameter value dropped even when
unrelated; identity text withheld on a coincidental match; and the residual
that an application-defined TRANSFORM of the value (a reversal, a hash) matches
nothing Flow can see.
docs/BROWSER_RECORDING.md,README.mdandclaims.yamlnow say all of this, including the correction to the false "its URL survives"
claim.
Independent re-review round 8 — a page that CONSUMES its own field
Round 6 of the external review reproduced four P1 leaks to
events.jsonl, allsilent. None is a regression from the previous round; all four are present at
07372d5too. It also verified the last-value cache as sound where it checked— one value per element, withhold-only, cannot capture a non-secret field,
cannot cross a document — and that is all kept.
The shape behind all four: a scanner input writes the badge into the URL (and
the title) and then clears its own field inside its own
inputhandler. Itholds nothing at any moment Python samples, and two rules keyed off what the
DOM holds at that instant.
CAUSE A — the cache guard was exclusive where it should be additive
identityMatchValues()consulted the cache only when NOTHING in the documentheld a value, while committed values were unioned unconditionally. The test is
now per element: a bound element holding nothing right now contributes its
cached value. That closes F4 (a second declared field holding a PIN re-exposed
the first field's value: the same URL was withheld before the PIN and reported
after it).
It must stay per element, not unconditional. Unioning every cached value
regressed three existing cases: a controlled input that swaps its node on every
keystroke leaves detached elements still reporting
c,ch,cha, and thosewithheld any text containing a one-letter match —
#chart-savecame backnull. A detached element is therefore added only when no connected boundfield holds anything, which covers a page that REMOVED its field (the
reviewer's A5 case) while excluding the swap trail.
That alone did not close F3. The cache holds one value per element, so a
second scan displaced badge one while badge one was still in the URL. A value
the next one does not continue was not edited away by the operator — the
page took it and started the field over — so it is promoted into the
withhold-only committed set, which is not per element. While the operator
types, each value continues the one before it, so no keystroke prefix can be
promoted. It is checked at the next
inputevent rather than at a microtaskcheckpoint after the current one: a checkpoint runs BETWEEN listeners, so
it observes the field before the page's own handler has cleared it. That cost
one wrong attempt and is worth recording.
CAUSE B —
documentHeldSecretValueread only live valuesIt stayed false forever for a consuming page. The title net sits inside that
flag while the URL net does not, so F1 leaked
"title_after": "Scan hunter2-primary"withtitle_withheld: null. Andstate.secretnever becametrue, so Python never learned the document had received a value.
The flag is now armed in the capture-phase
inputhandler, the moment thedocument provably held one — which is what the documentation already said,
"once a declared secret field RECEIVES INPUT".
Python had the same asymmetry, and it is F2's cause:
_track_secret_documentadded to
_secret_doc_idsfrom the input event but set_first_secret_doc_idonly from the settled read, and the cross-document rule keys off the second.
Both markers now move together through
_mark_secret_document.Two reviewer cases assert the pre-fix behaviour and cannot pass
test_r6_attack.py::test_attack_guard_is_not_consulted_while_a_value_is_liveand
::test_attack_cache_holds_one_value_per_elementare characterisations ofthe
d1a762edesign, and they contradict F3/F4 directly. I did not edit them.With
FIRST = "aaaaaaaaaaaaaaa#", A4 consumes FIRST, typesccccclive, puts/charts/aaaaaaaaaaaaaaain the URL, and assertsstate["url_withheld"] is None— the consumed value must be reported.test_r6_e2e2.py::test_second_scan_exposes_the_first_badgesets up the samesituation — consume the first badge, type a second one live, first badge still
in the path — and asserts
FIRST not in body, the consumed value must notreach disk. A3 is the same contradiction after two consumed entries.
I resolved it toward the leak: F3/F4 are P1 leaks to disk, A3/A4 assert that
the value stays out of the withhold set, which is what lets those leaks happen.
Eleven of the thirteen round-6 cases pass. Please confirm A3/A4 should be
retired or rewritten.
Reviewer files
d1a762etest_r4_repro*.py, 5 cases)test_r5_*.py, 12 cases)Round 6 run exactly as instructed: worktree contents symlinked into a run
directory, the five files plus the repo
tests/conftest.pyin itstests/,PYTHONPATHpinned to that directory and the import path asserted.New regression tests
d1a762etest_page_closure_withholds_a_title_a_consumed_field_produced(F1)test_an_input_event_alone_arms_the_cross_document_boundary(F2)test_page_closure_keeps_a_consumed_value_across_a_second_entry(F3)test_page_closure_keeps_a_consumed_value_while_another_field_is_live(F4)test_page_closure_still_reports_a_same_document_route_after_the_fixThe last is the did-not-regress guard for the SPA route evidence and passes at
both heads by design.
Evidence lost that was not already lost at
d1a762eOnly this: a document that receives declared input now counts as a secret
document even when it never holds a value at a sampling instant. For a page
that consumes its own field, every later document's URL and title are withheld
where they were previously reported — which is the F2 leak, so the "loss" is
the leak closing. Within such a document the URL and title are withheld once
the value appears, which is F1 closing.
Everything else is unchanged. The per-element cache rule adds matches only for
an element holding nothing, and the promotion adds only a value the page took
and started over from. Neither can fire while the operator is typing, so the
same-document SPA route evidence, the finding-1 exactness case and the node-swap
cases all still report exactly. No stated limit changed: an application-defined
TRANSFORM of the value still matches nothing Flow can see.
Validation at head
d7eabd1Rebased on
mainat059dca3. The rebase conflicted only in the generatedpublic-artifacts.json; it was regenerated from a clean export afterwards and reviewed.tests/test_browser_attach.py: 81 passed (see the live-CDP note below)passed with the two contradictory cases above
live CDP attach campaign
ruff check openadapt_flow,ruff format --check openadapt_flow tests,check_consistency.py,validate_claims.py --check --structure-only,check_release_consistency.pyon a cleangit archiveexport and again with--require-distagainst a built wheel and sdist: all pass. The artifactinventory was regenerated and reviewed — exactly
claims.yamlanddocs/verification.jsonchange, none added, none removed;ci.ymluntouched. The built wheel carries the round-6 recorder and none of the
deleted retention symbols.
Round-7 validation note (superseded, kept for the record)
Validation at head
d1a762eRebased on
mainat059dca3. The rebase conflicted only in the generatedpublic-artifacts.json; it was regenerated from a clean export afterwards and reviewed.tests/test_browser_attach.py: 77 passed, and the full fast unit suite5089 passed, 35 skipped
including the
testjob that runs the live CDP attach campaign. That onetest fails on the review machine right now at load average 8-17; it fails
identically at
07372d5under the same load, so it is contention, not thischange
ruff,mypy,check_consistency.py,validate_claims.py --check --structure-only,check_release_consistency.pyon a cleangit archiveexport and again with--require-distagainst a built wheel and sdist: all pass. The artifactinventory was regenerated and reviewed — exactly
claims.yamlanddocs/verification.jsonchange, none added, none removed;ci.ymluntouched.
Round-6 validation note (superseded, kept for the record)
Validation at head
07372d5Rebased on
mainat7633d45(which carries the apt-mirror fix, so thelinux-atspi-x11andcitrix-workspace-standininfrastructure failures seenon the previous head should not recur).
tests/test_browser_attach.py: 74 passed, including the live CDP attachcampaign
#section) now gets the same proof a named parametervalue gets, so it cannot pass through unreduced
including the
testjob that runs the live CDP attach campaignruff check openadapt_flow,ruff format --check openadapt_flow tests, andwhole-package
mypy(240 files) passscripts/check_consistency.pyandscripts/validate_claims.py --check --structure-onlypassscripts/check_release_consistency.pypasses on a cleangit archiveexport and again with
--require-distagainst a built wheel and sdist. Thepublic artifact inventory was regenerated and reviewed: exactly
claims.yamland
docs/verification.jsonchange, none added, none removed.ci.ymlisuntouched.
Round-5 validation note (superseded, kept for the record)
Validation at head
ff5d71eRebased on
mainat068b777. Every new case was run against7acd716source in a separate git worktree with
PYTHONPATHpinned to it, and theimport path was asserted before each run.
7acd716test_page_closure_keeps_all_evidence_for_a_password_starting_with_a_wordsidcame backMRN 44120 [secret] Alice Exampletest_page_closure_withholds_a_stale_reflection_from_a_swapping_inputcharlie-reached the event payload inurlandtitletest_page_closure_withholds_identity_that_holds_a_declared_value"sid": "Ticket [secret] owner", no markertest_page_closure_emits_no_reflected_text_from_the_capture_phaseurlandtitletest_page_closure_withholds_after_a_shorter_value_replaces_a_reflectiontest_page_closure_withholds_a_reflection_the_field_extended_pasttest_withheld_reflected_text_names_every_reason_for_the_operatortest_reflected_text_withheld_reasons_reach_the_recording_metadatatest_page_closure_states_its_limit_for_text_that_predates_the_value?token=[secret](the deliberate reduction above)The three prior reproductions stay closed, now by withholding rather than by
matching:
hunter2/ blur / return / Backspace, blur-clear-retype-shorter, andalpha-onethenalpha-one-two. Each of those tests asserts that the raw pagereally does still show the value, so none can pass on a page that never
reflected. The ambiguous multi-node refusal is unchanged and still asserted.
tests/test_browser_attach.py: 67 passed, including the live CDP attachcampaign
--ignore=tests/e2e --ignore=tests/test_install_playwright_browser.py): 4933 passed,35 skipped
mypy: 240 files, no issuesscripts/check_consistency.pyandscripts/validate_claims.py --check --structure-onlypassscripts/check_release_consistency.pypasses on a cleangit archiveexport, and again with
--require-distagainst a built wheel + sdist. Thepublic artifact inventory was regenerated with
--write-public-artifact-inventoryand reviewed: exactlyclaims.yamlanddocs/verification.jsonchange, none added, none removed.ci.ymlisuntouched, so the wheel gate's ci.yml pin is unaffected.
symbols
Correction to the round-4 note below. That note said the live CDP-attach
campaign cannot run on the review machine. It runs here:
test_live_cdp_attach_records_compiles_and_leaves_browser_running_three_trialsPASSED in 81s at this head, at load average ~3. The earlier timeout was
machine contention, not an incapable environment.
Claims.
claims.yamlgains four cited cases for this round, all of whichrun in the required
testjob, and the round-3 claim line "Redaction neverrewrites part of a URL authority and never rewrites its own output" is gone —
it described a mechanism that no longer exists.
Round-4 validation note (superseded, kept for the record)
Validation at head
7acd716mainat068b777(PR ci: prefer the canonical Ubuntu archive for the paper TeX install #374)5a871d4and again atc8f2aa5, including thetestjob that runs the live CDP attach campaign with its trial-1 lowercase secrettests/test_interactive_recorder.py,tests/test_recorder.py,tests/test_surface_selection.py,tests/test_compiler.py,tests/test_validate_claims.pyplus the attach file: 173 passedec04f25and passes here — theparent produced
"name": "Save [se[secret]ret]t","selector": null,"url": "http://ho[secret]t.te[secret]t/[secret]ign-in", and a"field_label"holding another declared secret's literaltests/test_browser_attach.py: 57 passed, 1 deselected; the four new cases all FAIL onc8f2aa5and pass here, checked by running this test file against that source (every live Chromiumcase except the CDP-attach campaign, see below)
mypy(240 files) passscripts/validate_claims.py --check --structure-onlypasses; the report wasregenerated and committed;
scripts/check_release_consistency.pypasses withthe public artifact inventory regenerated — its only hash changes are
claims.yamlanddocs/verification.jsonThe live CDP-attach campaign cannot run on the review machine. It fails in
start(), at the first evidence screenshot, withPage.screenshot: Timeout 30000ms exceeded, before any recorder logic runs. The same test on theunmodified parent commit
ec04f25hung past its 300s marker until it waskilled, so this is the environment (an externally launched Chromium reaching a
loopback HTTP server, on a host at load average 4–5), not this change. The
affected paths are proved in two halves instead: a unit case for the origin
guard, and real-Chromium cases for the page closure and for a launched-browser
recording. CI runs the campaign itself.
🤖 Generated with Claude Code