Skip to content

feat(browser): attach recorder to existing sessions - #364

Merged
abrichr merged 30 commits into
mainfrom
codex/browser-attach-recorder
Aug 20, 2026
Merged

feat(browser): attach recorder to existing sessions#364
abrichr merged 30 commits into
mainfrom
codex/browser-attach-recorder

Conversation

@abrichr

@abrichr abrichr commented Aug 18, 2026

Copy link
Copy Markdown
Member

What changed

  • add --browser-cdp-endpoint and --browser-page-url to attach Flow's Playwright recorder to one existing local Chromium tab
  • preserve an authenticated SSO or 2FA browser session without navigating or closing the external browser
  • bind the connection to loopback, one exact same-origin tab, one recording session, and bounded top-level events
  • retain the existing DOM identity, field geometry, exact before/after frame, compiler, and source-time secret-exclusion contract
  • support tab resize and monitor movement between actions by creating a fresh CSS-pixel viewport and device-scale baseline
  • store viewport_history, viewport_before, and viewport_after, and validate each event against its exact PNG during compilation
  • refuse only actions that overlap a resize or monitor-scale transition, because they lack an exact pre-action frame in the new coordinate space
  • document why Capture's custom Chrome extension remains a prototype alternate transport rather than a direct replay path
  • replace stale ecosystem integration proposals with the implemented Capture and Types boundaries

Why

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

  • remote CDP endpoints, URL credentials, ambiguous tabs, and cross-origin selection are refused
  • the selected tab must remain on the declared origin
  • iframe actions remain refused until cross-frame geometry and evidence are qualified
  • password and declared-secret values do not cross the page binding and their frame regions remain redacted
  • disconnect, invalid event evidence, or an overlapping resize leaves no complete recording metadata
  • direct extension replay is not promoted

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:

reproduced leak fix regression test
a page replaces a declared secret field before its first input event attribute records apply before rewrite matching; the removed node's sticky state transfers to its one proven replacement reordered-secret case in test_page_closure_scrubs_replaced_prefilled_and_reflected_secrets; type_and_click_replaced_secret in the live CDP campaign
a field loses its name/id while typing classification is sticky to the DOM element + input session; an attribute change cannot remove secrecy prefilled-secret case (identity removed before the first input)
a child frame or closed shadow root appears during screenshot preparation the closed-root inventory runs as a screenshot guard, so the root is bound or refused before any bytes exist; launched mode gets the same initial inventory 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 case
a reordered multi-node replacement every candidate is masked AND its first input refuses the recording, instead of assigning one removed field's identity to all of them ambiguous-secret case

Also closed in this round:

  • selectors and cached labels derived from a secret value are scrubbed against that field's live value (label-equals-secret)
  • the origin guard reads only location.origin, so a secret reflected into the query string cannot cross into Python
  • ARIA textboxes contribute their value to the scrub set; a printable keydown on a bound secret field no longer emits a hotkey; native non-text controls no longer cause a false batch refusal
  • marker cleanup pierces open shadow roots, and a session-keyed cleanup handle survives a page that replaces the global recorder object
  • the privacy scan scopes resolved nodes to one protocol object group and releases it after each scan

Finalization lifecycle repair

Arming the finalization latch before the privacy guard made an ordinary
same-origin history.replaceState from the recording itself alias a
post-evidence frame change, which refused every valid recording that reflects
state into the URL. finish() now runs the privacy guard first — its page
round-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
test job 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.

reproduced defect fix regression test
a 1–2 character prefix rewrote unrelated identity: a click on #chart-save recorded {"selector": null, "name": "Save [se[secret]ret]t"}, and the DOM identity tier disarmed silently redaction is bound to the ELEMENT: the closure reads the field's CURRENT value at scrub time and never retains a prefix; values are committed only at change / focusout / submit / pagehide; one left-to-right pass over the original text never re-reads its own output test_page_closure_keeps_url_and_identity_evidence_for_a_lowercase_secret
attach mode aborted with a false diagnosis: with hunter2 on http://host.test/app every emitted URL became "[secret]ttp://[secret]ost.test/app", so the first keystroke refused the recording as cross-origin emit sends location.origin beside 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 rewritten test_attached_recorder_reads_the_origin_the_page_reports plus the URL assertions in the lowercase case
corrupted URL evidence on disk in launched mode ("url_after": "h[secret][secret]p://127.0.0.1:65479/") as above the lowercase case and the GET-form case
a trusted field-label cache leaked ANOTHER declared secret's literal into field_label the cached label is scrubbed against every declared value, not only the field's own value test_page_closure_scrubs_a_cached_label_holding_another_declared_secret
a declared value survived a same-origin GET form submit into url_after / title_after each document builds its own closure, so a document that does not hold the value cannot scrub it: Flow withholds that document's URL and title, records structural_text_withheld in meta.json, and reports it test_launched_recording_withholds_url_evidence_after_a_get_form_submit, test_structural_text_is_withheld_after_a_secret_leaves_its_document
meta.json was mutated AFTER the atomic publish, so a crash could publish a surface-unbound recording the recorder stamps the surface before it publishes; the CLI stamp is a no-op when the value is already there test_stamping_a_recorded_surface_does_not_rewrite_a_published_recording
the CLI asserted "values NOT stored" exactly where that was false the line states what Flow guarantees, and record prints what it withheld and why test_recording_privacy_notices_report_what_flow_withheld

Two rules keep the fix from trading one failure for another:

  • Short values still fail closed. A value too short to tell a real
    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.
  • Identity never disarms silently. Where an element's own id, data-testid,
    data-test or name holds a declared value, the action carries
    identity_withheld, meta.json carries identity_withheld_events, and
    record prints how many actions carry no DOM selector and why.

The test blind spot is closed. Every live secret in test_browser_attach.py
was 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 the
button 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 focusout in the middle of typing, so the commit
points recorded c, ch, cha, … as declared values, and every later scrub
became 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.

blocker fix regression test
P1 regression. Type hunter2, blur (which commits it), return and press Backspace: the live value hunter was 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 there a value the field currently holds is never dropped, and a value is committed under the ELEMENT that held it, so change/focusout commit 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 it test_page_closure_redacts_a_value_the_field_still_holds_after_a_commit (round 5 renamed it test_page_closure_withholds_reflected_text_the_field_no_longer_matches: same scenario, now closed by withholding)
P2. 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 per keystroke and every later scrub became ambiguous a replacement inherits the state AND input session of the node it replaced, whether the mutation batch or a focus transfer proves the replacement test_page_closure_keeps_evidence_when_an_unnamed_password_swaps_its_node
P2. A withheld accessible name, role, or clicked-row identity disarmed an identity check with no marker; only a withheld identity ATTRIBUTE was marked scrubIdentityText records the reason, structuralTarget reports it in identity_withheld, a withheld row identity travels as sid_withheld, and the recorder counts the action once test_page_closure_marks_a_withheld_name_and_row_identity, test_withheld_row_identity_is_counted_for_the_operator

Two 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 invoice out of
    invoice-2026-quarterly-passphrase and then silently rewrote unrelated
    identity 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.
  • The ambiguous multi-node rewrite still refuses. Discovery order is
    unchanged; a replacement discovery bound in the same batch is tracked
    separately (batchDiscoveredSecrets) so it can still inherit its
    predecessor's session.

Accepted correction to the round-3 PR text: "two declared fields never share a
key" was inexact — name="x" on one element and id="x" on another are the
same declared field x to the operator and share the key. Two different
declared NAMES never share one.

Also taken: scrubSecretUrl returns the URL the page reports when it redacted
nothing, 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() sampled location.href and
document.title inside a CAPTURE-phase listener
(addEventListener(type, handler, true)), so it read them before the page's
own oninput had run: the values described the state BEFORE the action. The
value 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 settled
boundary, the same boundary that captures the after-frame. Events now carry no
reflected text at all, and that boundary is the only sampling point.

round-5 blocker fix regression test
P1. A password beginning with a common word (invoice-2026-quarterly-passphrase) made the word invoice a retained value for the rest of the recording. A clicked row's identity MRN 44120 invoice Alice Example was rewritten to MRN 44120 [secret] Alice Example with 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 too nothing is retained: matching uses only the values that CONNECTED bound elements hold at match time. At click time the only declared value is the complete passphrase, which none of that evidence contains test_page_closure_keeps_all_evidence_for_a_password_starting_with_a_word
P1. A node-swapping controlled input combined with an as-you-type reflection: the page showed charlie- after the field had moved on to charlie-alpha, and the retention rule dropped charlie- as a superseded prefix, so it reached the recording verbatim reflected text 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: an origin-only URL and an empty title test_page_closure_withholds_a_stale_reflection_from_a_swapping_input
Identity evidence was rewritten rather than withheld, invisibly to every downstream check identity (selector, role, accessible name, clicked-row identity, receiving field name) is EXACT or WITHHELD with a stated reason test_page_closure_withholds_identity_that_holds_a_declared_value

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). interactive_recorder.py is
347 lines removed against 313 added.

Kept, unchanged. input[type=password] auto-detection beside declared
--secret fields; attributeOldValue handling and stateFromPriorDeclaration
re-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 a
URL that ALREADY contains the password and then types it into a declared field,
7acd716 rewrote that URL to ?token=[secret]; this head reports it. The URL
predates 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.md and pinned by
test_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. 7acd716 leaked the
same way.

Closed by structure, not by matching. A URL is now parsed instead of being
treated as one opaque string:

  • The origin and the path are reported. A path change is the single-page
    application case, and a path is app structure rather than operator input.
  • Every parameter name survives, in the query and in a key=value fragment.
  • A parameter value is dropped when its NAME is a declared --secret FIELD
    name, or the name/id of any bound field (which includes every
    auto-detected input[type=password]). Deterministic, and it never looks at
    the 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.
  • A value 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 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.
  • A document born after an earlier document received a declared value has no
    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 Referer headers, with or without Flow. The net checks
a 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
focusout on the node it just replaced commits no keystroke prefix; without
that, prefixes such as c and ch withheld unrelated identity on every chance
match.

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() returned null for a bound secret field with no reason, so a
declared field with an aria-label produced a silent null while a control
field beside it returned its name. It now reports
identity_withheld: secret-field-name-not-read and 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, so preSecretTitle was '' and any titled page withheld
both 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 so
explicitly — safePageState refreshes both baselines at every sample taken
while 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

script at 7acd716 at ff5d71e here
test_r4_repro.py (results page pre-fills) PASS PASS PASS
test_r4_repro2.py (results page does not pre-fill) FAIL FAIL PASS
test_r4_repro3.py::test_a (SPA wizard summary row) PASS FAIL — the regression PASS
test_r4_repro3.py::test_b (new-document row) FAIL FAIL PASS
test_r4_repro4.py (silent null accessible name) FAIL FAIL PASS

Run unedited, with PYTHONPATH pinned to each head and the import path
asserted before each run.

New regression tests

Nine, each FAILING at ff5d71e and passing here, verified with a pinned
PYTHONPATH in a separate worktree:

test closes
test_launched_recording_drops_a_declared_url_parameter_after_a_get_submit P1-A, live Chromium
test_page_closure_withholds_identity_from_an_inbound_declared_parameter P1-A + P1-B across documents
test_page_closure_withholds_identity_after_the_field_is_removed P1-B, the regression
test_page_closure_marks_a_withheld_secret_field_name P2-A
test_page_closure_reports_a_single_page_app_route_change the evidence the old refusal destroyed
test_page_closure_drops_only_the_unproven_parameter_value one value dropped, path and other parameters exact
test_page_closure_withholds_a_url_that_holds_the_value_in_its_path the net, whole URL withheld, operator warned
test_page_closure_warns_when_the_application_puts_a_secret_in_its_url the net on a non-declared parameter name
test_page_closure_states_its_debounce_limit pins the stated residual

The four expected outcomes the design called for are the middle five rows.

What is still lost, stated plainly

  • A title is withheld for the rest of a document once it changes after a
    declared field has held a value, and for every document after that one. A
    title has no structure to reduce.
  • A parameter value Flow cannot prove predates the value is dropped even
    when it is unrelated to it. Only that value; the name, path and other
    parameters stay exact.
  • Identity text is withheld whenever it contains a value Flow can see,
    including by coincidence. Fail-safe, and counted in
    identity_withheld_events.
  • The residual, unchanged and deliberate. An application that updates its
    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_limit and written into
    docs/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_label
keeping 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.jsonl through the URL path. It also verified both deviations from
the 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 was
the value-matching net, and that net is EMPTY in a fresh document — no bound
element holds anything, nothing was committed there, and
inboundSecretParameterValues reads the query and the fragment but never the
pathname. 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>:

HEAD 07372d5   "url_after": "http://127.0.0.1:.../results/hunter2-primary"
PREV ff5d71e   "url_after": "http://127.0.0.1:.../"      (withheld, clean)

A regression, and worse than a plain leak: meta.json said
application_placed_secret_in_url and structural_text_withheld, so the
operator 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.pushState and
history.replaceState do not build a new document, so the closure that held
the 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_fix and
test_a_same_document_route_is_never_treated_as_a_later_document pin it, and
the 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 input handler. By the time Python samples, nothing in
the DOM holds the value, and change/focusout then fire on an empty field,
so every value source was empty and no warning fired at all.

The capture-phase input listener runs BEFORE the page's handler — the last
moment 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

  • 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. Widening can only withhold more.
  • Comparison is case-insensitive. Upper-casing an identifier before showing
    it is normalisation, not an application-defined transform. This closes the
    reviewer's attack case 4.
  • 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.
  • The two dead bindings (names, reason_key) are removed.

Reviewer round-5 reproductions

All twelve cases across the five files pass. With PYTHONPATH pinned:

file at 07372d5 here
test_r5_e2e.py FAIL PASS
test_r5_e2e2.py (same-document, field cleared) FAIL PASS
test_r5_e2e3.py (REST redirect) FAIL PASS
test_r5_e2e4.py (selector from a row id) FAIL PASS
test_r5_attack.py (6 cases) 2 FAIL, 4 PASS 6 PASS

The round-4 reproductions (test_r4_repro*.py) still pass: 5 passed.

New regression tests

test at 07372d5 here
test_launched_recording_withholds_a_redirect_that_puts_the_value_in_a_path FAIL PASS
test_launched_recording_withholds_a_later_document_url_after_a_get_submit FAIL PASS
test_a_same_document_route_is_never_treated_as_a_later_document FAIL PASS
test_page_closure_withholds_a_selector_built_from_an_inbound_value FAIL PASS
test_page_closure_still_reports_a_same_document_route_after_the_fix PASS PASS (by design)

What 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, a
history.replaceState, and a query change inside the document that held the
value 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.md and claims.yaml
now 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, all
silent. None is a regression from the previous round; all four are present at
07372d5 too. 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 input handler. It
holds 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 document
held 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 those
withheld any text containing a one-letter match — #chart-save came back
null. A detached element is therefore added only when no connected bound
field 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 input event rather than 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. That cost
one wrong attempt and is worth recording.

CAUSE B — documentHeldSecretValue read only live values

It 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" with title_withheld: null. And state.secret never became
true, so Python never learned the document had received a value.

The flag 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, and it is F2's cause: _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.

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_live
and ::test_attack_cache_holds_one_value_per_element are characterisations of
the d1a762e design, and they contradict F3/F4 directly. I did not edit them.

With FIRST = "aaaaaaaaaaaaaaa#", A4 consumes FIRST, types ccccc live, puts
/charts/aaaaaaaaaaaaaaa in the URL, and asserts
state["url_withheld"] is None — the consumed value must be reported.
test_r6_e2e2.py::test_second_scan_exposes_the_first_badge sets up the same
situation — 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 not
reach 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

set at d1a762e here
round 4 (test_r4_repro*.py, 5 cases) pass 5 pass
round 5 (test_r5_*.py, 12 cases) pass 12 pass
round 6 (13 cases) 6 FAIL 11 pass, 2 contradictory

Round 6 run exactly as instructed: worktree contents symlinked into a run
directory, the five files plus the repo tests/conftest.py in its tests/,
PYTHONPATH pinned to that directory and the import path asserted.

New regression tests

test at d1a762e here
test_page_closure_withholds_a_title_a_consumed_field_produced (F1) FAIL PASS
test_an_input_event_alone_arms_the_cross_document_boundary (F2) FAIL PASS
test_page_closure_keeps_a_consumed_value_across_a_second_entry (F3) FAIL PASS
test_page_closure_keeps_a_consumed_value_while_another_field_is_live (F4) FAIL PASS
test_page_closure_still_reports_a_same_document_route_after_the_fix PASS PASS

The 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 d1a762e

Only 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 d7eabd1

Rebased on main at 059dca3. The rebase conflicted only in the generated public-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)
  • reviewer files: round 4 5 passed, round 5 12 passed, round 6 11
    passed
    with the two contradictory cases above
  • fast unit suite as CI runs it: 5102 passed, 35 skipped, including the
    live CDP attach campaign
  • every required GitHub check passes at this exact head (15 pass, 3 skip)
  • ruff check openadapt_flow,
    ruff format --check openadapt_flow tests, check_consistency.py,
    validate_claims.py --check --structure-only,
    check_release_consistency.py on a clean git archive export and again with
    --require-dist against a built wheel and sdist: all pass. The artifact
    inventory was regenerated and reviewed — exactly claims.yaml and
    docs/verification.json change, none added, none removed; ci.yml
    untouched. 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 d1a762e

Rebased on main at 059dca3. The rebase conflicted only in the generated public-artifacts.json; it was regenerated from a clean export afterwards and reviewed.

  • reviewer round-5 files: 12 passed; round-4 files: 5 passed
  • tests/test_browser_attach.py: 77 passed, and the full fast unit suite
    5089 passed, 35 skipped
  • every required GitHub check passes at this exact head (15 pass, 3 skip),
    including the test job that runs the live CDP attach campaign. That one
    test fails on the review machine right now at load average 8-17; it fails
    identically at 07372d5 under the same load, so it is contention, not this
    change
  • fast unit suite as CI runs it, ruff, mypy, check_consistency.py,
    validate_claims.py --check --structure-only,
    check_release_consistency.py on a clean git archive export and again with
    --require-dist against a built wheel and sdist: all pass. The artifact
    inventory was regenerated and reviewed — exactly claims.yaml and
    docs/verification.json change, none added, none removed; ci.yml
    untouched.

Round-6 validation note (superseded, kept for the record)

Validation at head 07372d5

Rebased on main at 7633d45 (which carries the apt-mirror fix, so the
linux-atspi-x11 and citrix-workspace-standin infrastructure failures seen
on the previous head should not recur).

  • tests/test_browser_attach.py: 74 passed, including the live CDP attach
    campaign
  • the four reviewer scripts: 5 passed
  • fast unit suite as CI runs it: 4940 passed, 35 skipped
  • a bare URL fragment (#section) now gets the same proof a named parameter
    value gets, so it cannot pass through unreduced
  • every required GitHub check passes at this exact head (15 pass, 3 skip),
    including the test job that runs the live CDP attach campaign
  • ruff check openadapt_flow, ruff format --check openadapt_flow tests, and
    whole-package mypy (240 files) pass
  • scripts/check_consistency.py and
    scripts/validate_claims.py --check --structure-only pass
  • scripts/check_release_consistency.py passes on a clean git archive
    export and again with --require-dist against a built wheel and sdist. The
    public artifact inventory was regenerated and reviewed: exactly claims.yaml
    and docs/verification.json change, none added, none removed. ci.yml is
    untouched.

Round-5 validation note (superseded, kept for the record)

Validation at head ff5d71e

Rebased on main at 068b777. Every new case was run against 7acd716
source in a separate git worktree with PYTHONPATH pinned to it, and the
import path was asserted before each run.

new test at 7acd716 here
test_page_closure_keeps_all_evidence_for_a_password_starting_with_a_word FAIL — sid came back MRN 44120 [secret] Alice Example PASS
test_page_closure_withholds_a_stale_reflection_from_a_swapping_input FAIL — charlie- reached the event payload in url and title PASS
test_page_closure_withholds_identity_that_holds_a_declared_value FAIL — "sid": "Ticket [secret] owner", no marker PASS
test_page_closure_emits_no_reflected_text_from_the_capture_phase FAIL — every event carried url and title PASS
test_page_closure_withholds_after_a_shorter_value_replaces_a_reflection FAIL PASS
test_page_closure_withholds_a_reflection_the_field_extended_past FAIL PASS
test_withheld_reflected_text_names_every_reason_for_the_operator FAIL — one reason only PASS
test_reflected_text_withheld_reasons_reach_the_recording_metadata FAIL PASS
test_page_closure_states_its_limit_for_text_that_predates_the_value FAIL — ?token=[secret] (the deliberate reduction above) PASS

The three prior reproductions stay closed, now by withholding rather than by
matching: hunter2 / blur / return / Backspace, blur-clear-retype-shorter, and
alpha-one then alpha-one-two. Each of those tests asserts that the raw page
really 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 attach
    campaign
  • fast unit suite as CI runs it (--ignore=tests/e2e --ignore=tests/test_install_playwright_browser.py): 4933 passed,
    35 skipped
  • Ruff check + format pass; whole-package mypy: 240 files, no issues
  • scripts/check_consistency.py and
    scripts/validate_claims.py --check --structure-only pass
  • scripts/check_release_consistency.py passes on a clean git archive
    export, and again with --require-dist against a built wheel + sdist. The
    public artifact inventory was regenerated with
    --write-public-artifact-inventory and reviewed: exactly claims.yaml and
    docs/verification.json change, none added, none removed. ci.yml is
    untouched, so the wheel gate's ci.yml pin is unaffected.
  • the built wheel carries the new recorder and none of the deleted retention
    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_trials
PASSED in 81s at this head, at load average ~3. The earlier timeout was
machine contention, not an incapable environment.

Claims. claims.yaml gains four cited cases for this round, all of which
run in the required test job, and the round-3 claim line "Redaction never
rewrites 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 7acd716

  • rebased onto main at 068b777 (PR ci: prefer the canonical Ubuntu archive for the paper TeX install #374)
  • every required GitHub check passed at 5a871d4 and again at c8f2aa5, including the test job that runs the live CDP attach campaign with its trial-1 lowercase secret
  • tests/test_interactive_recorder.py, tests/test_recorder.py,
    tests/test_surface_selection.py, tests/test_compiler.py,
    tests/test_validate_claims.py plus the attach file: 173 passed
  • every new case fails on the parent commit ec04f25 and passes here — the
    parent 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 literal
  • tests/test_browser_attach.py: 57 passed, 1 deselected; the four new cases all FAIL on c8f2aa5 and pass here, checked by running this test file against that source (every live Chromium
    case except the CDP-attach campaign, see below)
  • Ruff check + format and mypy (240 files) pass
  • scripts/validate_claims.py --check --structure-only passes; the report was
    regenerated and committed; scripts/check_release_consistency.py passes with
    the public artifact inventory regenerated — its only hash changes are
    claims.yaml and docs/verification.json

The live CDP-attach campaign cannot run on the review machine. It fails in
start(), at the first evidence screenshot, with Page.screenshot: Timeout 30000ms exceeded, before any recorder logic runs. The same test on the
unmodified parent commit ec04f25 hung past its 300s marker until it was
killed, 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

@abrichr

abrichr commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Second independent review: DO NOT MERGE

Reviewed at exact head ec04f25 in a clean worktree. Five reproductions run against live Chromium. Load average 2.7–6.2 throughout, so no contention artefacts.

P1 (blocker) — short secret prefixes become sticky scrub values

rememberSecretValue (interactive_recorder.py:472) runs on every input event, so stickySecretValues retains every intermediate value including 1- and 2-character prefixes. scrubTextWithSecretValues (:477) then substring-replaces each retained value across all later URL, title, label, selector and structural text — iterating over the already-substituted string, so it rewrites the inside of its own [secret] placeholder.

1. Silent identity degradation, all modes. Secret charlie1, then a click on an unrelated button:

BEFORE: {"selector": "#chart-save", "role": "button", "name": "Save chart"}
AFTER : {"selector": null,          "role": "button", "name": "Save [se[secret]ret]t"}

uniqueSelector (:592) returns null whenever scrubbing alters the element id, so the compiler's DOM-identity tier silently disarms for any element whose id/name/data-testid contains one character of the prefix. Nothing refuses; the bundle compiles and looks healthy.

2. Attach mode aborts with a false diagnosis. emit writes the scrubbed location.href into every event and _enqueue_browser_event parses it as the origin. On http://host.test/app with secret hunter2, every emitted URL became "[secret]ttp://[secret]ost.test/app", and the first keystroke aborted the recording with "a browser event came from outside the declared application origin" — a navigation that never happened. Any secret whose first typed character appears anywhere in the tab URL kills the recording.

3. Corrupted URL evidence on disk (launched mode): "url_after": "h[secret][secret]p://127.0.0.1:65479/".

Why CI is green: every live secret in tests/test_browser_attach.py is an uppercase phrase sharing no character with http://127.0.0.1:<port>/. Lowercase secrets fail.

P2 — trusted field-label cache leaks another declared secret

bindSecretState (:653:658) caches fieldLabel(el) once while the field is empty; fieldLabel (:920) scrubs only against that element's own current value, never stickySecretValues. A secret field appearing earlier in document order caches a later field's prefilled value. Reproduced: "field_label": "Confirm PREFILLED-A-SECRET-NEVER-PERSIST". This is a same-document leak, so it breaks the contract README.md and docs/BROWSER_RECORDING.md both state.

P2 — declared secret survives a same-origin GET form submit

The init script builds a fresh closure per document (:355), so stickySecretValues is empty after a real navigation. Reproduced "url_after": "…?token=TOKEN-SECRET-NEVER-PERSIST" plus matching title. The compiled bundle is clean; only the recording directory holds it. This is disclosed in the README and docs, hence P2 — but it is the most likely way an operator actually loses a declared secret.

P3 — the CLI asserts the opposite

__main__.py:962967 prints Secret field(s) recorded (values NOT stored), which is false in the case above, at exactly the moment the operator decides whether the recording is safe to share.

P3 — meta.json mutated after the atomic publish

_stamp_recording_surface runs after _promote_recording. A crash in that window publishes a recording with no surface key, which by its own docstring "compiles to a legacy, surface-unbound bundle" — contradicting this PR's "published only when complete" claim.

Claim accuracy

claims.yaml says the live campaign covers "URL/title reflection", but both reflection tests use history.replaceState only. That is same-document coverage and should say so.

Held up under adversarial reading

Iframe refusal and in-frame password pixel masking (verified solid black in every retained frame); _wait_settled using the masked guarded screenshot; closed-shadow-root refusal before every retained capture; popup / new tab / tab close / late frame change / CDP disconnect all reaching abort() with no published recording; RENAME_NOREPLACE publication with the temp prefix excluded from git, wheel and sdist; per-event viewport bound to actual PNG dimensions with range-checked pointer coordinates.

Rounds one and two closed real defects. The masking, fail-closed and artifact-boundary work is thorough. The P1 is the blocker.

🤖 Generated with Claude Code

@abrichr
abrichr force-pushed the codex/browser-attach-recorder branch from ec04f25 to e82ef2e Compare August 19, 2026 03:30
@abrichr

abrichr commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Re-review at c8f2aa5: DO NOT MERGE — the redesign traded corruption for a plaintext leak

Reviewed in a clean worktree against parent ec04f25, real Chromium, load average 4–5. All findings reproduced.

P1 (blocker, NEW) — the prefix-drop rule discards a real secret

interactive_recorder.py:548-561 (retainedSecretValues), rule at :551-560. The rule drops a value when another value of the same field starts with it. It cannot distinguish a keystroke prefix from a real earlier value, so a longer committed value suppresses the field's own current value.

The operator makes one typo and corrects it:

  1. Type hunter2, blur — change/focusout commit hunter2.
  2. Return to the field, press Backspace once. Live value is hunter — 6 chars, so not the short/ambiguous case.
  3. retainedSecretValues returns {hunter2} only; hunter is dropped as a "prefix".
  4. hunter is never redacted anywhere.

End-to-end in launched mode with --secret token, written to events.jsonl:

{"i": 5, "kind": "click", ...,
 "url_after":   "http://127.0.0.1:54721/charts/hunter",
 "title_after": "session for hunter"}

meta.json carries no withheld marker, _recording_privacy_notices returns [], and the CLI prints the new promise at __main__.py:967-975 — "each value is redacted from the recorded URL, title, label, and structural text" — which is false here.

Three shapes reproduce: blur-then-Backspace; blur, clear, retype shorter; and two values on one field where the earlier is a prefix of the later (alpha-one then alpha-one-two leaves alpha-one in the clear).

This is a regression. The same page and script on parent ec04f25 produce session for [secret] — badly corrupted, but it does not leak.

P2 (NEW) — a field with no name and no id is uncovered

secretSessionFor (:505-515) and declaredSecretState (:901-909, field: n || i || null). A bare <input type="password"> has no field key, so it falls back to the input session; a controlled input that swaps its node takes a new session per swap, so no prefix is ever dropped, every 1-character prefix survives, and every later scrub is ambiguous. Reproduced on an unrelated button:

{"selector": null, "role": "button", "name": null,
 "identity_withheld": "ambiguous-secret-in-identity"}
"url": "http://host.test/hospital/[secret]"

That is the "fail closed but useless" outcome commit 5a871d4 claims to close; its regression test uses name='swap-secret' and so misses the unnamed case. Also, the PR body's "Two declared fields never share a key" is not exact: name="x" and a different element's id="x" both key to field:x.

P2 (prior finding not fully closed) — the accessible name still disarms silently

targetName (:739-768) vs identityRefusal/uniqueSelector (:771-810). identity_withheld is set only when an identity attribute holds a declared value. targetName calls scrubSecretText, gets null on ambiguity, and sets no marker; structuredIdentity (:690-720) has the same gap. Reproduced with one character typed, clicking a <button>Save chart</button> with no id:

{"selector": null, "role": "button", "name": null}

No marker, not counted in identity_withheld_events, nothing reported to the operator. So docs/BROWSER_RECORDING.md:165 ("can never disarm silently") and the PR's "Identity never disarms silently" are both too strong.

Hypotheses tested and rejected — no defect

  • Origin guard is NOT forgeable. Object.defineProperty(window,'location',…) and …(location,'origin',…) both throw in Chromium; delete window.location returns false; document.domain does not alter location.origin; overriding Location.prototype.origin does not touch the instance's own unforgeable property. __oaflow_origin stayed exact in every trial, and source.frame is page.main_frame still holds. Correction to my own brief: the old design also parsed a page-supplied string (the scrubbed location.href), so location.origin is a smaller trust surface, not a larger one.
  • Never-committed values are redacted — verified for a removed <input> and a removed contenteditable; removing the focused node fires focusout, which commits.
  • One-pass replacement is correct — secret equal to [secret], café-latte, a.*b+c?d, emoji: all clean. Needles sort longest-first, index advances past each match, no regex.
  • Token-wise URL redaction holds — query key, value and fragment all redact whole.

Prior findings, fix by fix

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

  1. retainedSecretValues must 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.
  2. Give a declared secret field a stable key when it has no name and no id; add a regression case for an unnamed swapping password field.
  3. Mark and count a withheld accessible name and sid as 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

@abrichr
abrichr force-pushed the codex/browser-attach-recorder branch from c8f2aa5 to 7acd716 Compare August 19, 2026 05:08
@abrichr

abrichr commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Round 4 at 7acd716: three blockers closed, still DO NOT MERGE

Thanks — the P1 was real and I have treated it as a regression I introduced. Reproduced it with your exact shape before changing anything: type hunter2 → blur → Backspace left "url": "http://host.test/charts/hunter" and "title": "session for hunter" in events.jsonl on c8f2aa5.

Root cause accepted: the rule inferred a keystroke prefix from string shape, which cannot tell a prefix from a real earlier value. Retention now tracks WHERE a value came from.

  • A value the field currently holds is never dropped.
  • A committed value is filed under the ELEMENT that held it 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 its successor happens to hold at that instant — that was how prefixes were becoming "commit points" in the first place.
  • A value is droppable only where a controlled input REPLACED the node holding it (supersededSecretElements, set at the proven single-node mutation transfer and at the focus transfer to a replacement of a disconnected active element), the page no longer holds that node, and the same field went on to hold a value that continues it.

Blocker 2: 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, so a password field with no name and no id keeps one identity across every swap. Discovery order is unchanged — I tried moving it and it silently disabled the ambiguous-multi-node refusal, so batch-discovered replacements are tracked separately instead.

Blocker 3: marking, not weakening. scrubIdentityText records the reason; a withheld accessible name or role reports identity_withheld; a withheld clicked-row identity travels as sid_withheld; the recorder counts the action once. docs/BROWSER_RECORDING.md now states the retention rule and lists all three withheld kinds.

One case your list did not name, which the fix had to keep true: a page that reflects a field as the operator types writes the PREVIOUS value into the title/URL. Dropping prefixes outright leaked that. Each element now keeps the last value it held plus every earlier value long enough to identify, so a stale reflection is still redacted without retaining the 1-character prefixes that corrupt unrelated evidence. Covered by the blocker-1 test, whose page reflects on every input.

P3 nit taken: scrubSecretUrl returns the URL the page reports when it redacted nothing.

Correction accepted: "two declared fields never share a key" was inexact. name="x" and another element's id="x" are the same declared field x to the operator and do share the key; two different declared NAMES never do. Fixed in the PR body.

Non-vacuity, checked the way you checked it: all four new cases fail against c8f2aa5 source and pass at this head.

  • 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

tests/test_browser_attach.py: 57 passed, 1 deselected (the live CDP campaign, same environment limit as before — it fails in start() at the first screenshot on this host and passed in CI at the two previous heads). Ruff, mypy, release consistency, and the wheel/source-boundary gates pass. Rebased onto 068b777.

Not merging. Back to you.

🤖 Generated with Claude Code

@abrichr

abrichr commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Round-three review at 7acd716: DO NOT MERGE — two P1s, and the retention rule is not converging

Narrow review of the retention/provenance logic only. Rounds one and two already cleared masking, iframes, popups, publication, artifact boundary, origin guard, one-pass replacement and token-wise URL redaction; none of that was redone.

Round two's three blockers: all CLOSED

Verified by copying the 7acd716 test file over c8f2aa5 source — all four new tests fail at c8f2aa5 and pass at 7acd716, so they are not vacuous.

The ambiguous multi-node refusal is INTACT

Three probes, all passing: two candidates replacing one declared field (the exact ambiguous-secret shape) gives one privacy_refusal, zero input events, no plaintext; reordered replacement fires the refusal; three candidates with two bound as declared passwords fires the refusal. batchDiscoveredSecrets only widens unboundAddedEntries and can never turn an ambiguous batch into a proven one.

Finding 1 — P1, NEW at 7acd716. Round one's defect has returned in a new form.

interactive_recorder.py:544-560 (noteObservedValue) and :628-636. Every earlier value of an element ≥6 chars is kept. For a normal (not superseded) element all of them go into retainedByKey at :630, and :634-636 retain them with no drop rule at all. A value of length L leaves L−5 permanent needles.

Reproduced — password invoice-2026-quarterly-passphrase typed into <input name="token">, page never reflects it anywhere:

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

@abrichr

abrichr commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Round 4 at ff5d71e: DO NOT MERGE — three bounded blockers, but the mechanism is sound

Scoped to the retention replacement. Rounds 1–3 already cleared masking, iframes, popups, publication and the artifact boundary.

Headline: this is no longer a redesign cycle. Rounds 1–3 each broke the same rule while fixing it. Round 4 finds three bounded defects, two of which are pre-existing rather than regressions. The verdict is: fix these three, re-run the reproductions, do not redesign again.

P1-A — a same-origin GET submit keeps the full URL. REPRODUCED. Not a regression.

interactive_recorder.py:2462-2474 (_secret_document_left), :675-706 (safePageState).

The new document seeds preSecretUrl from location.href, and that URL already holds the value. Python adds the document to _secret_doc_ids as soon as it holds a declared value, so the cross-document rule stops applying. The page then compares the leaked URL against a baseline that is the leaked URL, finds no change, and reports it.

With --secret token, page 1 GET-submits #token, page 2 is /results?token=<SECRET> and also carries a field named token; one keystroke into it yields:

"url_before": "http://127.0.0.1:.../results?token=hunter2-token-value"

while meta.json says structural_text_withheld: secret-value-left-its-document. docs/BROWSER_RECORDING.md:204-209 promises the opposite for this exact scenario. 7acd716 leaks the same way.

P1-B — the deleted commit points lost a protection. REPRODUCED. REGRESSION.

:557-575 (liveSecretValues, connected elements only), :1409-1412.

An SPA wizard: type into declared #token, "Next" removes the form and renders a summary row holding the value, click the row.

"structured_identity": "Tokenhunter2-token-value"    // no sid_withheld

The same test passes at 7acd716, where the focusout/change commit kept the value matchable after removal. The deleted test test_page_closure_redacts_a_value_the_field_still_holds_after_a_commit covered this. docs/BROWSER_RECORDING.md:221-225 pre-declares the limit, so the new code matches the written boundary and the old code exceeded it. Decide deliberately.

P2-A — a silent null accessible name. REPRODUCED at both heads.

:766-769targetName() starts if (secretTextEntryForNode(el)) return null; and never sets identityWithheldReason. Clicking a declared secret field with an aria-label gives "name": null with no identity_withheld and no identity_withheld_events. A control field in the same page returns its name, so the null is suppression, not absence. Same class as the round-3 blocker, moved from selector to name. One line.

Attacks that held

Value typed then node replaced; two fields sharing a value; closed shadow root; iframe refusal; paste; IME; autofill; screenshot masking.

Protections after the deletion

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

@abrichr
abrichr force-pushed the codex/browser-attach-recorder branch 2 times, most recently from 07372d5 to d1a762e Compare August 19, 2026 22:48
abrichr and others added 16 commits August 19, 2026 19:57
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>
abrichr and others added 14 commits August 19, 2026 19:58
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>
@abrichr
abrichr force-pushed the codex/browser-attach-recorder branch from 9d25e67 to d7eabd1 Compare August 19, 2026 23:59
@abrichr
abrichr marked this pull request as ready for review August 20, 2026 00:31
@abrichr
abrichr merged commit a5a0bbb into main Aug 20, 2026
18 checks passed
@abrichr
abrichr deleted the codex/browser-attach-recorder branch August 20, 2026 00:31
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.

1 participant