@@ -104,8 +104,39 @@ export function collectSnapshot(): unknown {
104104 // A reveal toggle flips the field to type="text" without making its
105105 // contents any less secret, and some forms never use type="password" at
106106 // all. The autocomplete token is the page's own declaration either way.
107+ // Space-separated detail tokens are spec-legal and WebAuthn recommends
108+ // `current-password webauthn`, so whole-string equality missed real values
109+ // on exactly the type=text credential fields where autocomplete is the
110+ // only signal there is.
107111 const hint = String ( el . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
108- return hint === 'current-password' || hint === 'new-password'
112+ return hint
113+ . split ( / \s + / )
114+ . some ( ( token ) => token === 'current-password' || token === 'new-password' )
115+ }
116+
117+ /**
118+ * Fields whose value is as sensitive as a password but which the agent must
119+ * still be able to FILL: one-time codes and payment details.
120+ *
121+ * Deliberately separate from isSecretField. That one also gates keystrokes
122+ * (activeElementSecrecy feeds the driver's press-key guard), so folding these
123+ * tokens into it would stop the agent completing a checkout or an OTP prompt —
124+ * work it is legitimately asked to do. Only the value is withheld here.
125+ */
126+ const isSensitiveValueField = ( el : Element | null ) : boolean => {
127+ if ( ! el || el . tagName !== 'INPUT' ) return false
128+ const hint = String ( el . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
129+ return hint
130+ . split ( / \s + / )
131+ . some (
132+ ( token ) =>
133+ token === 'one-time-code' ||
134+ token === 'cc-number' ||
135+ token === 'cc-csc' ||
136+ token === 'cc-exp' ||
137+ token === 'cc-exp-month' ||
138+ token === 'cc-exp-year'
139+ )
109140 }
110141
111142 const roleFor = ( el : Element ) : string => {
@@ -170,7 +201,8 @@ export function collectSnapshot(): unknown {
170201 // like any other. Redaction above is realm-safe and runs first, so
171202 // widening this cannot expose a credential field.
172203 const value = ( el as HTMLInputElement ) . value
173- if ( value ) parts . push ( `value="${ cut ( String ( value ) , 120 ) } "` )
204+ if ( value && isSensitiveValueField ( el ) ) parts . push ( 'value-withheld' )
205+ else if ( value ) parts . push ( `value="${ cut ( String ( value ) , 120 ) } "` )
174206 }
175207 if ( el . tagName === 'A' ) {
176208 const href = el . getAttribute ( 'href' )
@@ -271,8 +303,14 @@ export function clickElement(id: number): unknown {
271303 const isSecretField = ( node : Element | null ) : boolean => {
272304 if ( ! node || node . tagName !== 'INPUT' ) return false
273305 if ( String ( ( node as HTMLInputElement ) . type || '' ) . toLowerCase ( ) === 'password' ) return true
306+ // Space-separated detail tokens are spec-legal and WebAuthn recommends
307+ // `current-password webauthn`, so whole-string equality missed real values
308+ // on exactly the type=text credential fields where autocomplete is the
309+ // only signal there is.
274310 const hint = String ( node . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
275- return hint === 'current-password' || hint === 'new-password'
311+ return hint
312+ . split ( / \s + / )
313+ . some ( ( token ) => token === 'current-password' || token === 'new-password' )
276314 }
277315
278316 const el = ( window . __simAgentElements || [ ] ) [ id ]
@@ -321,8 +359,14 @@ export function focusElementForTyping(id: number): unknown {
321359 const isSecretField = ( node : Element | null ) : boolean => {
322360 if ( ! node || node . tagName !== 'INPUT' ) return false
323361 if ( String ( ( node as HTMLInputElement ) . type || '' ) . toLowerCase ( ) === 'password' ) return true
362+ // Space-separated detail tokens are spec-legal and WebAuthn recommends
363+ // `current-password webauthn`, so whole-string equality missed real values
364+ // on exactly the type=text credential fields where autocomplete is the
365+ // only signal there is.
324366 const hint = String ( node . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
325- return hint === 'current-password' || hint === 'new-password'
367+ return hint
368+ . split ( / \s + / )
369+ . some ( ( token ) => token === 'current-password' || token === 'new-password' )
326370 }
327371
328372 const el = ( window . __simAgentElements || [ ] ) [ id ]
@@ -372,8 +416,39 @@ export function readActiveElementState(): unknown {
372416 const isSecretField = ( node : Element | null ) : boolean => {
373417 if ( ! node || node . tagName !== 'INPUT' ) return false
374418 if ( String ( ( node as HTMLInputElement ) . type || '' ) . toLowerCase ( ) === 'password' ) return true
419+ // Space-separated detail tokens are spec-legal and WebAuthn recommends
420+ // `current-password webauthn`, so whole-string equality missed real values
421+ // on exactly the type=text credential fields where autocomplete is the
422+ // only signal there is.
423+ const hint = String ( node . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
424+ return hint
425+ . split ( / \s + / )
426+ . some ( ( token ) => token === 'current-password' || token === 'new-password' )
427+ }
428+
429+ /**
430+ * Fields whose value is as sensitive as a password but which the agent must
431+ * still be able to FILL: one-time codes and payment details.
432+ *
433+ * Deliberately separate from isSecretField. That one also gates keystrokes
434+ * (activeElementSecrecy feeds the driver's press-key guard), so folding these
435+ * tokens into it would stop the agent completing a checkout or an OTP prompt —
436+ * work it is legitimately asked to do. Only the value is withheld here.
437+ */
438+ const isSensitiveValueField = ( node : Element | null ) : boolean => {
439+ if ( ! node || node . tagName !== 'INPUT' ) return false
375440 const hint = String ( node . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
376- return hint === 'current-password' || hint === 'new-password'
441+ return hint
442+ . split ( / \s + / )
443+ . some (
444+ ( token ) =>
445+ token === 'one-time-code' ||
446+ token === 'cc-number' ||
447+ token === 'cc-csc' ||
448+ token === 'cc-exp' ||
449+ token === 'cc-exp-month' ||
450+ token === 'cc-exp-year'
451+ )
377452 }
378453
379454 // Focus inside a frame or an open shadow root surfaces on the outer document
@@ -413,6 +488,17 @@ export function readActiveElementState(): unknown {
413488 redacted : true ,
414489 }
415490 }
491+ // Reported as the real tag rather than 'password-field': the agent may
492+ // still type here, it just never learns what is already in the field.
493+ if ( isSensitiveValueField ( active ) ) {
494+ return {
495+ activeElement : active . tagName . toLowerCase ( ) ,
496+ selectedChars : 0 ,
497+ valueLength : 0 ,
498+ valuePreview : '' ,
499+ redacted : true ,
500+ }
501+ }
416502 let value = ''
417503 let selectedChars = 0
418504 if ( active . tagName === 'INPUT' || active . tagName === 'TEXTAREA' ) {
@@ -451,8 +537,14 @@ export function activeElementSecrecy(): string {
451537 const isSecretField = ( node : Element | null ) : boolean => {
452538 if ( ! node || node . tagName !== 'INPUT' ) return false
453539 if ( String ( ( node as HTMLInputElement ) . type || '' ) . toLowerCase ( ) === 'password' ) return true
540+ // Space-separated detail tokens are spec-legal and WebAuthn recommends
541+ // `current-password webauthn`, so whole-string equality missed real values
542+ // on exactly the type=text credential fields where autocomplete is the
543+ // only signal there is.
454544 const hint = String ( node . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
455- return hint === 'current-password' || hint === 'new-password'
545+ return hint
546+ . split ( / \s + / )
547+ . some ( ( token ) => token === 'current-password' || token === 'new-password' )
456548 }
457549
458550 let active = document . activeElement as HTMLElement | null
@@ -463,6 +555,34 @@ export function activeElementSecrecy(): string {
463555 active = shadow . activeElement as HTMLElement
464556 continue
465557 }
558+ // A CLOSED shadow root reports `shadowRoot === null` by design and cannot
559+ // be traversed from script, while focus inside it retargets to the HOST —
560+ // whose tagName is never INPUT, so the fallthrough below would call it
561+ // 'safe' and let a trusted CDP keystroke land on a password field the page
562+ // has hidden from us. Sequential focus navigation crosses closed boundaries
563+ // natively, so Tab alone is enough to get there. Treated like a
564+ // cross-origin frame: not inspectable is not safe.
565+ //
566+ // Detected by focusability rather than by tag: `attachShadow` accepts plain
567+ // div/span/section as well as custom elements, so a tag test would miss
568+ // half of them. An element that is not focusable in its own right cannot be
569+ // `activeElement` unless focus was retargeted out of a shadow tree, which
570+ // makes "not focusable yet focused" the reliable signal. Frames stay out of
571+ // it so the branch below still classifies them.
572+ const focusableItself =
573+ active === document . body ||
574+ active . isContentEditable ||
575+ active . hasAttribute ( 'tabindex' ) ||
576+ active . tagName === 'INPUT' ||
577+ active . tagName === 'TEXTAREA' ||
578+ active . tagName === 'SELECT' ||
579+ active . tagName === 'BUTTON' ||
580+ active . tagName === 'A' ||
581+ active . tagName === 'AREA' ||
582+ active . tagName === 'SUMMARY' ||
583+ active . tagName === 'IFRAME' ||
584+ active . tagName === 'FRAME'
585+ if ( ! shadow && ! focusableItself ) return 'opaque'
466586 if ( active . tagName === 'IFRAME' || active . tagName === 'FRAME' ) {
467587 let inner : Document | null = null
468588 try {
@@ -486,8 +606,14 @@ export function typeIntoElement(id: number, text: string, submit: boolean): unkn
486606 const isSecretField = ( node : Element | null ) : boolean => {
487607 if ( ! node || node . tagName !== 'INPUT' ) return false
488608 if ( String ( ( node as HTMLInputElement ) . type || '' ) . toLowerCase ( ) === 'password' ) return true
609+ // Space-separated detail tokens are spec-legal and WebAuthn recommends
610+ // `current-password webauthn`, so whole-string equality missed real values
611+ // on exactly the type=text credential fields where autocomplete is the
612+ // only signal there is.
489613 const hint = String ( node . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
490- return hint === 'current-password' || hint === 'new-password'
614+ return hint
615+ . split ( / \s + / )
616+ . some ( ( token ) => token === 'current-password' || token === 'new-password' )
491617 }
492618
493619 const el = ( window . __simAgentElements || [ ] ) [ id ]
@@ -556,8 +682,14 @@ export function pressKeyOnPage(
556682 const isSecretField = ( node : Element | null ) : boolean => {
557683 if ( ! node || node . tagName !== 'INPUT' ) return false
558684 if ( String ( ( node as HTMLInputElement ) . type || '' ) . toLowerCase ( ) === 'password' ) return true
685+ // Space-separated detail tokens are spec-legal and WebAuthn recommends
686+ // `current-password webauthn`, so whole-string equality missed real values
687+ // on exactly the type=text credential fields where autocomplete is the
688+ // only signal there is.
559689 const hint = String ( node . getAttribute ( 'autocomplete' ) || '' ) . toLowerCase ( )
560- return hint === 'current-password' || hint === 'new-password'
690+ return hint
691+ . split ( / \s + / )
692+ . some ( ( token ) => token === 'current-password' || token === 'new-password' )
561693 }
562694
563695 const target = ( document . activeElement as HTMLElement | null ) ?? document . body
0 commit comments