From d3042349e5bc64078d2c62e30f69677e357e34ba Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 12:50:09 -0500 Subject: [PATCH 1/5] feat(flow): optional Chrome extension shadow-DOM evidence First slice of #1872: Manifest V3 unpacked extension that captures open-shadow-DOM target paths while a recording is active, redacts secrets in the content script, shows a REC badge, and delivers lines through a local native-messaging host into extension_evidence.jsonl. Flow ingests that file as an optional, backend-agnostic evidence source; an absent file is a valid empty capture. Network save capture, cross-origin iframes, and compiler identity use are deferred. --- packages/flow/chrome_extension/README.md | 56 ++++ packages/flow/chrome_extension/background.js | 115 +++++++ packages/flow/chrome_extension/content.js | 191 ++++++++++++ .../flow/chrome_extension/icons/icon128.png | Bin 0 -> 2998 bytes .../flow/chrome_extension/icons/icon16.png | Bin 0 -> 386 bytes .../flow/chrome_extension/icons/icon48.png | Bin 0 -> 1223 bytes packages/flow/chrome_extension/manifest.json | 40 +++ .../native_host/com.openadapt.extension.json | 9 + .../native_host/openadapt_extension_host.py | 89 ++++++ .../flow/docs/CHROME_EXTENSION_EVIDENCE.md | 41 +++ .../flow/openadapt_flow/extension_evidence.py | 186 ++++++++++++ .../chrome_extension_harness.js | 282 ++++++++++++++++++ .../flow/tests/test_extension_evidence.py | 151 ++++++++++ .../tests/test_extension_shadow_evidence.py | 238 +++++++++++++++ 14 files changed, 1398 insertions(+) create mode 100644 packages/flow/chrome_extension/README.md create mode 100644 packages/flow/chrome_extension/background.js create mode 100644 packages/flow/chrome_extension/content.js create mode 100644 packages/flow/chrome_extension/icons/icon128.png create mode 100644 packages/flow/chrome_extension/icons/icon16.png create mode 100644 packages/flow/chrome_extension/icons/icon48.png create mode 100644 packages/flow/chrome_extension/manifest.json create mode 100644 packages/flow/chrome_extension/native_host/com.openadapt.extension.json create mode 100755 packages/flow/chrome_extension/native_host/openadapt_extension_host.py create mode 100644 packages/flow/docs/CHROME_EXTENSION_EVIDENCE.md create mode 100644 packages/flow/openadapt_flow/extension_evidence.py create mode 100644 packages/flow/tests/extension_support/chrome_extension_harness.js create mode 100644 packages/flow/tests/test_extension_evidence.py create mode 100644 packages/flow/tests/test_extension_shadow_evidence.py diff --git a/packages/flow/chrome_extension/README.md b/packages/flow/chrome_extension/README.md new file mode 100644 index 0000000000..35e1024ea7 --- /dev/null +++ b/packages/flow/chrome_extension/README.md @@ -0,0 +1,56 @@ +# Optional Chrome extension evidence (everyday Chrome) + +**Status:** unpack-first scaffold for [OpenAdapt#1872](https://github.com/OpenAdaptAI/OpenAdapt/issues/1872). +Not a store listing. Not required for recording. + +OpenAdapt records everyday Chrome through the OS accessibility tree with no +install. Accessibility cannot see open shadow DOM detail, cross-origin +iframes, or network saves. This Manifest V3 extension is an optional evidence +source for those gaps. Without it, recording still works. + +## This slice (Part of #1872) + +- Content script builds a shadow-piercing target path across **open** shadow + roots (`host >>> inner`). +- Closed shadow roots are marked (`>>closed`) and stop the walk. +- Password and declared-secret field values are replaced with `` + before a message leaves the content script. +- Evidence is sent only while a recording is active; the action badge shows + `REC`. +- A local native-messaging host stub appends lines to + `extension_evidence.jsonl` in the recording directory. +- The Flow engine reads that file through + `openadapt_flow.extension_evidence` and treats a missing file as empty. + +Not in this slice (follow-up PRs): `chrome.debugger` / `webRequest` network +save capture, full cross-origin iframe DOM reads, compiler use of extension +paths in identity checks, and Chrome Web Store packaging. + +## Load unpacked (local only) + +1. Open `chrome://extensions`, enable Developer mode, Load unpacked → this + directory. +2. Note the extension id. Edit `native_host/com.openadapt.extension.json`: + set `path` to the absolute path of `native_host/openadapt_extension_host.py` + (or a small wrapper script) and `allowed_origins` to + `chrome-extension:///`. +3. Install the host manifest where Chrome expects it for your OS. +4. Set `OPENADAPT_EXTENSION_RECORDING` to the recording directory before the + host starts. + +Do not use this in a sensitive profile until network capture and reconnect +acknowledgement land. Prefer Flow's Playwright recorder or CDP attach for +governed browser recording today +([BROWSER_RECORDING.md](../docs/BROWSER_RECORDING.md)). + +## Tests without Chrome + +```bash +cd packages/flow +pytest tests/test_extension_evidence.py tests/test_extension_shadow_evidence.py +``` + +The shadow tests run the content and background scripts in Node against a fake +DOM. Real Chromium coverage for native messaging and multi-frame pages needs a +headed or headless Chrome later (office lab was unreachable at first +implementation). diff --git a/packages/flow/chrome_extension/background.js b/packages/flow/chrome_extension/background.js new file mode 100644 index 0000000000..11f3a81ea0 --- /dev/null +++ b/packages/flow/chrome_extension/background.js @@ -0,0 +1,115 @@ +// OpenAdapt optional extension evidence (service worker). +// Relays content-script evidence to the local native host only while recording. +// Shows a visible REC badge. No remote endpoints. +'use strict'; + +const NATIVE_HOST = 'com.openadapt.extension'; +const SCHEMA = 'openadapt.extension-evidence/1'; + +let recording = false; +let port = null; +let queued = []; + +function setBadge(active) { + const text = active ? 'REC' : ''; + const color = active ? '#b00020' : '#00000000'; + try { + chrome.action.setBadgeText({ text }); + chrome.action.setBadgeBackgroundColor({ color }); + chrome.action.setTitle({ + title: active + ? 'OpenAdapt extension evidence (recording)' + : 'OpenAdapt extension evidence (idle)', + }); + } catch (e) {} +} + +function ensurePort() { + if (port) return port; + try { + port = chrome.runtime.connectNative(NATIVE_HOST); + port.onDisconnect.addListener(() => { + port = null; + }); + } catch (e) { + port = null; + } + return port; +} + +function deliver(evidence) { + if (!recording) return; + const p = ensurePort(); + if (!p) { + // Native host missing: keep a short in-memory queue for tests / attach later. + if (queued.length < 1000) queued.push(evidence); + return; + } + try { + p.postMessage({ type: 'EXTENSION_EVIDENCE', schema: SCHEMA, evidence }); + } catch (e) { + if (queued.length < 1000) queued.push(evidence); + } +} + +function broadcastRecording(active) { + chrome.tabs.query({}, (tabs) => { + for (const tab of tabs || []) { + if (!tab.id) continue; + try { + chrome.tabs.sendMessage(tab.id, { type: 'SET_RECORDING', active }); + } catch (e) {} + } + }); +} + +function setRecording(active) { + recording = Boolean(active); + setBadge(recording); + broadcastRecording(recording); + if (!recording) queued = []; +} + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (!msg || typeof msg !== 'object') return; + if (msg.type === 'SET_RECORDING') { + setRecording(Boolean(msg.active)); + sendResponse({ ok: true, active: recording }); + return true; + } + if (msg.type === 'CONTENT_READY') { + sendResponse({ active: recording }); + return true; + } + if (msg.type === 'EXTENSION_EVIDENCE') { + if (recording && msg.evidence) deliver(msg.evidence); + sendResponse({ ok: true, active: recording }); + return true; + } + if (msg.type === 'GET_STATE') { + sendResponse({ active: recording, queued: queued.length }); + return true; + } +}); + +// Expose for the Node harness. +if (typeof globalThis !== 'undefined') { + globalThis.__openadaptExtensionBackground = { + setRecording, + deliver, + get recording() { + return recording; + }, + get queued() { + return queued.slice(); + }, + _reset() { + recording = false; + queued = []; + port = null; + setBadge(false); + }, + }; +} + +setBadge(false); diff --git a/packages/flow/chrome_extension/content.js b/packages/flow/chrome_extension/content.js new file mode 100644 index 0000000000..9122facefc --- /dev/null +++ b/packages/flow/chrome_extension/content.js @@ -0,0 +1,191 @@ +// OpenAdapt optional extension evidence (content script). +// Captures open-shadow-DOM target paths while a recording is active. +// Never sends password / declared-secret values. No remote endpoints. +'use strict'; + +(function () { + const SCHEMA = 'openadapt.extension-evidence/1'; + const SECRET = ''; + const SECRET_NAME = /pass(word|wd|phrase)?\b|passwd|pwd|secret|private[_-]?key|^pin$|^otp$/i; + + let recording = false; + let seq = 0; + const attached = { document: 0, window: 0 }; + + function isSecretField(el) { + if (!el || el.nodeType !== 1) return false; + const type = (el.getAttribute && el.getAttribute('type')) || el.type || ''; + if (String(type).toLowerCase() === 'password') return true; + if (el.getAttribute && el.getAttribute('data-openadapt-secret') != null) return true; + const name = (el.getAttribute && (el.getAttribute('name') || el.getAttribute('id'))) || ''; + return Boolean(name && SECRET_NAME.test(String(name))); + } + + function cssEscapeIdent(value) { + // Minimal CSS.escape for idents used in selectors. + return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function stepFor(el) { + if (!el || el.nodeType !== 1) return null; + const tag = (el.tagName || '').toLowerCase(); + if (!tag) return null; + if (el.id) return `${tag}#${cssEscapeIdent(el.id)}`; + const name = el.getAttribute && el.getAttribute('name'); + if (name) return `${tag}[name="${cssEscapeIdent(name)}"]`; + const parent = el.parentNode; + if (!parent || !parent.children) return tag; + const siblings = Array.from(parent.children).filter( + (c) => (c.tagName || '').toLowerCase() === tag + ); + if (siblings.length <= 1) return tag; + const index = siblings.indexOf(el) + 1; + return `${tag}:nth-of-type(${index})`; + } + + // Build a path that pierces open shadow roots: "host >>> inner >>> leaf". + // Closed shadow roots are marked and stop the walk (page JS cannot read them). + function shadowPiercingPath(el) { + const parts = []; + let node = el; + let crossedShadow = false; + let closedShadow = false; + + while (node && node.nodeType === 1) { + const step = stepFor(node); + if (step) parts.push(step); + const root = node.getRootNode && node.getRootNode(); + if (root && root !== document && root.host) { + crossedShadow = true; + // Open shadow: continue from the host. Closed: stop. + if (root.mode === 'closed') { + closedShadow = true; + parts.push('>>closed'); + break; + } + parts.push('>>>'); + node = root.host; + continue; + } + node = node.parentElement; + } + parts.reverse(); + // Collapse ">>> " separators already pushed as tokens. + const path = []; + for (const p of parts) { + if (p === '>>>' || p === '>>closed') { + path.push(p); + } else { + path.push(p); + } + } + return { + path: path.join(' '), + in_shadow: crossedShadow, + closed_shadow: closedShadow, + }; + } + + function targetEvidence(el, eventType) { + if (!el || el.nodeType !== 1) return null; + const walk = shadowPiercingPath(el); + const secret = isSecretField(el); + const evidence = { + schema: SCHEMA, + kind: 'target', + event: eventType, + tag: (el.tagName || '').toLowerCase(), + role: (el.getAttribute && el.getAttribute('role')) || null, + name: (el.getAttribute && (el.getAttribute('name') || el.getAttribute('aria-label'))) || null, + id: el.id || null, + path: walk.path, + in_shadow: walk.in_shadow, + closed_shadow: walk.closed_shadow, + frame_url: (typeof location !== 'undefined' && location.href) || null, + is_iframe: (typeof window !== 'undefined' && window.top !== window) || false, + secret_field: secret, + value: secret ? SECRET : null, + seq: seq++, + t_ms: Date.now(), + }; + // Never put a live secret value on the wire. + if (secret) evidence.value = SECRET; + else if (typeof el.value === 'string' && el.value && eventType === 'input') { + evidence.value = el.value; + } + return evidence; + } + + function emit(evidence) { + if (!recording || !evidence) return; + try { + chrome.runtime.sendMessage({ type: 'EXTENSION_EVIDENCE', evidence }); + } catch (e) { + // Extension context gone: fail closed (drop). + } + } + + function onClick(ev) { + emit(targetEvidence(ev.target, 'click')); + } + + function onFocusIn(ev) { + emit(targetEvidence(ev.target, 'focus')); + } + + function onInput(ev) { + emit(targetEvidence(ev.target, 'input')); + } + + function attach() { + if (attached.document) return; + document.addEventListener('click', onClick, true); + document.addEventListener('focusin', onFocusIn, true); + document.addEventListener('input', onInput, true); + attached.document = 3; + } + + function detach() { + if (!attached.document) return; + document.removeEventListener('click', onClick, true); + document.removeEventListener('focusin', onFocusIn, true); + document.removeEventListener('input', onInput, true); + attached.document = 0; + } + + function setRecording(active) { + recording = Boolean(active); + if (recording) attach(); + else detach(); + } + + chrome.runtime.onMessage.addListener((msg) => { + if (!msg || typeof msg !== 'object') return; + if (msg.type === 'SET_RECORDING') { + setRecording(Boolean(msg.active)); + } + }); + + // Ask background for current state on load (idempotent). + try { + chrome.runtime.sendMessage({ type: 'CONTENT_READY' }, (resp) => { + if (resp && typeof resp.active === 'boolean') setRecording(resp.active); + }); + } catch (e) {} + + // Expose for the Node harness (no Chrome). + if (typeof globalThis !== 'undefined') { + globalThis.__openadaptExtensionContent = { + setRecording, + shadowPiercingPath, + targetEvidence, + isSecretField, + get attached() { + return { ...attached }; + }, + get recording() { + return recording; + }, + }; + } +})(); diff --git a/packages/flow/chrome_extension/icons/icon128.png b/packages/flow/chrome_extension/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..8b3c137747e4ae97d90911146b8c4b76fd371e81 GIT binary patch literal 2998 zcmV;n3rX~eP) zTc}+}8OMM7oRcOwxwVqwtwGUP@kK;XTYbnwrTAc#iU^9J^@4c6GzC##Lz>q5#;7jB?fJB1#yF5YoBZ17i_W@^!)&P9r&w)pQo7}ZQ_#=Zz z@Kd6>KJNJWt-EG2i$GISZi;C08^BeRGZ_bsDG>njojQvG*Nh5NBS1h5fdCUnAi%`2 z(LtSk*RE$?>H@8y061LtXx1Z@m+>sW`b0iOUqf^~FPL+mE$#J_?q9CZPipmzXY#_q97EJH6KA-@#( zCB;Lsrk>v6`fbqeXwCVzW5b%i)Y@gr>-;Y7&0meY&Gq9TEj2y)`-pt*m+Ah=8bb03 z(Krd3X4ecjhc#@_!&!@vrKHhw-TS|Gv|$BkWo?o2d)oI?L6TtAUl*IMybDKA9DR2T zb%j=(QC$;YL8Kr~5i`s$(%D1M6#*7lqt62`!t{jEhF&K?ZQTc)aDUY&t)dSjz+=Eu z9p6V+kcHG4x+VaaYgY5!)JA|Tw{$D$0|8|J!gL4#%kAzAgY+^5)Q3QTi6anT;s^wo zI06ACj;5@DCQZ6LXGznVbyxK_(x-J3zz_Xu3SFB{UDdC8YYi8xlK}pW>1OMWYg+^M zkp2`ZrmiV;QGZgO)<%N52%z}2TYwLF-|xUW466CL)!^%Z2Z5a|`_9!Vr?7niFH-N2 z*>?RZ_XWVuS#}y72fj~_Vg^N`Td@6fiblOGfV04Ump_XjQdXZ!c4W~hq8-5NTv(~Q ze*Lx|>jS?}^3p}%R+qo>16Kr0gwi(Re&xcvxjkSJ=6wI{aZwjLhD?sdPtMPvP zfS^ujV|lyl`h{Bhyp9`gGmL4DMG&Q#VP{8Pe;k$jz7GK*MIfig!@%>&cEl)@y3P@) z>-rnRPzWH*u>jJk)?Yj7AVAwppXy>z5-Pics}f*=+5ggj+t3HP9$LGuAEJBMq~iUo zYPZV-IS+i6NF(h-=Zqcx!}bMC2AxGzi$I?ua)tWGv8DO9%dQ2C4Q18>;;)eFU4qYe zWp&zjvVlc{CKiLzpvHIMfn2LlZ%0sK?Tu3@=PmA^N__YuFTO_Cc}g#Z%pZr}&5oXfyRy=&^;glfjJ#q@ciw%qQ% zD?@^!1b8qbZ*QR?a)zZ^2 z3WX~&1(hMdAP=srn*b8dZY=o37k*r6(&xVbAH(AK^2$xaGqHUQPo~rnC&7DNzC02H z0#J?s>d-H`d>7d+e7|g8NN2GT;{#a0<^ht2>UDQ7*JI}me+&38%Yx6c)phFl8@=+< z&>I2_Wie=hb--7EBfv{Afh66(%u;pee&7u*%`tQ86!0IST|xI?N5mb%cI&vxEANU| zX4<{Yu{eRBVTzj~rY4Ifb%3W6o%^Fe$t`M3!;c8P3}|?$4|ZsyGS__{!_q*6Yen!fdH&V09fweo3WF=d?J)NCcbQn zk2nutdWG(*Xd40nSe*cZI%Wr^?LWeHL6z~Z=RNH{vHk|UJKwy|mWTbIg zdt1?enrfIj?Hq6nYh+WNv!oHhPesen(|;%MTA~e@6o21TN`eTyE9Lh=z;C3&x61@& zR9(R~tvkbf2Z!9> zxEZK>x&ezZ-0Oai-gXm$GFL?O|4iR7F+5`$UlNA0yh#DF}Z=m;vv?cJhc1`69zb?A0y^ zw*F`_16~b$gXm>YjS6pe*975@6mh{m4?DT!B+&+rLHHxbjP0j|=(Vb#?N1DT>|b#5 sOc+7<0RaI40RaI40RaI40RhYSKXKM3#}jjGK>z>%07*qoM6N<$g0#A()&Kwi literal 0 HcmV?d00001 diff --git a/packages/flow/chrome_extension/icons/icon16.png b/packages/flow/chrome_extension/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..de3bf198622e0649a7fd5652a4a0db34a3bdfa32 GIT binary patch literal 386 zcmV-|0e$|7P)`S;C9dt7O;a(S=$Kr z3|~Wa6_1OjJ;9^I4)HWKRZA_}0Ix&89lAqI1;gaEF^e~;88u@$#fz*RY~nk92S?aQ z>;%t~d;SCwY%Aww>|q@rv5Unt_~c@GRXN64^(2;(w2X(j=?ji=57QC$s3~kl*2Q~# zi%_2vUkigRY^#4}-{miGZXMjhgXr)Q2e^-U%()ohL>s78KgQkvDJY-Q$}O+d8c>T? g`3LRmtTie47g?AHtL(ZDqB_A{`FlFf=Sp$4*hTa&|UqW8;9}~NOZyh;Aw_~lED<*2Rx^J-vd7p zc74{DM{fd}={y9y5Bvh0qr#oD^6yoEaFx{Ou~NI=Y6P_EF%C7=c6ZHfOQG zD`3KH0Z!}q5hRz%!!{La0DSIvcm-^&t2wenBSCg;C+V(u1#Cx;>)5~g`J46~1fJ3M zGGdZfmB#X%50r;@qo%+$Rk+1E?_20tLnrV!@Gaq#*iwbi*lFNV(o!>|fYlRYQ6O8p zz6o5(5!ScMz-t9-RZ?I*??Np%&#JQla1!_icoBFBF`b=CHxFC_z5{+h+@n3O_zJJq zXUEV9EY!V72D6Om1kCqtM)Hz*q$aqDxY!jWTHaPVE6OdxS~h8yGV9BIK~uGg%Y{FI z6(>;0uGdt7Wuzc)>)ZzECVd24N0eX8X-i1{(*ypbhPPA;LZ@;B54A3%8SBvs^J^{Y z%@w{4JOMmK`t;%9i*b9rvhVKzhY{aDgCuf!$(w&xc~$2}rw@6s9cUpt+t$wxQhaw1 zC}5Cx&$|08NaNCDrjAunt&q5q9} z1?CZxF+aWp?AGy#6dr~ZFnjMKUgrfJ8^`}}wZiI#w~%+uJIK>GOo8xOB*2sUec~=` l8N8iOo$v-Wa literal 0 HcmV?d00001 diff --git a/packages/flow/chrome_extension/manifest.json b/packages/flow/chrome_extension/manifest.json new file mode 100644 index 0000000000..b8244e30aa --- /dev/null +++ b/packages/flow/chrome_extension/manifest.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "OpenAdapt Extension Evidence (optional)", + "version": "0.1.0", + "description": "Optional everyday-Chrome evidence for OpenAdapt recordings: open shadow DOM targets while a recording is active. Not required; accessibility recording still works without it.", + "permissions": [ + "activeTab", + "tabs", + "scripting", + "storage", + "nativeMessaging" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_start", + "all_frames": true + } + ], + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "action": { + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "default_title": "OpenAdapt extension evidence (idle)" + } +} diff --git a/packages/flow/chrome_extension/native_host/com.openadapt.extension.json b/packages/flow/chrome_extension/native_host/com.openadapt.extension.json new file mode 100644 index 0000000000..d259a54c63 --- /dev/null +++ b/packages/flow/chrome_extension/native_host/com.openadapt.extension.json @@ -0,0 +1,9 @@ +{ + "name": "com.openadapt.extension", + "description": "OpenAdapt optional Chrome extension evidence host", + "path": "HOST_PATH_PLACEHOLDER", + "type": "stdio", + "allowed_origins": [ + "chrome-extension://EXTENSION_ID_PLACEHOLDER/" + ] +} diff --git a/packages/flow/chrome_extension/native_host/openadapt_extension_host.py b/packages/flow/chrome_extension/native_host/openadapt_extension_host.py new file mode 100755 index 0000000000..309fe92a1b --- /dev/null +++ b/packages/flow/chrome_extension/native_host/openadapt_extension_host.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Native messaging host stub for the optional OpenAdapt Chrome extension. + +Chrome talks to this process over stdin/stdout (length-prefixed JSON). Each +EXTENSION_EVIDENCE message is appended to the recording directory named in +OPENADAPT_EXTENSION_RECORDING (or --recording). No remote calls. + +This is the unpack-first host for local development. A store listing and +installer come later (OpenAdapt#1872). +""" + +from __future__ import annotations + +import argparse +import json +import os +import struct +import sys +from pathlib import Path + +# Allow running from a source checkout without install. +_FLOW_ROOT = Path(__file__).resolve().parents[2] +if str(_FLOW_ROOT) not in sys.path: + sys.path.insert(0, str(_FLOW_ROOT)) + +from openadapt_flow.extension_evidence import ( # noqa: E402 + ExtensionEvidenceBuffer, + validate_native_message, +) + + +def read_message() -> dict | None: + raw_len = sys.stdin.buffer.read(4) + if not raw_len: + return None + (length,) = struct.unpack(" None: + data = json.dumps(message).encode("utf-8") + sys.stdout.buffer.write(struct.pack(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--recording", + default=os.environ.get("OPENADAPT_EXTENSION_RECORDING", ""), + help="Recording directory that receives extension_evidence.jsonl", + ) + args, _unknown = parser.parse_known_args(argv) + if not args.recording: + # Fail closed: without a recording dir we acknowledge nothing useful. + write_message({"ok": False, "error": "no_recording_dir"}) + return 2 + + recording = Path(args.recording) + buf = ExtensionEvidenceBuffer() + # Reload any existing lines so reconnects append. + existing = recording / "extension_evidence.jsonl" + if existing.is_file(): + from openadapt_flow.extension_evidence import load_extension_evidence + + for line in load_extension_evidence(recording): + buf.append(line) + + while True: + msg = read_message() + if msg is None: + break + try: + evidence = validate_native_message(msg) + except ValueError as exc: + write_message({"ok": False, "error": str(exc)}) + continue + buf.append(evidence) + buf.write(recording) + write_message({"ok": True, "count": buf.count}) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/flow/docs/CHROME_EXTENSION_EVIDENCE.md b/packages/flow/docs/CHROME_EXTENSION_EVIDENCE.md new file mode 100644 index 0000000000..0c24a5443c --- /dev/null +++ b/packages/flow/docs/CHROME_EXTENSION_EVIDENCE.md @@ -0,0 +1,41 @@ +# Optional Chrome extension evidence + +Part of [OpenAdapt#1872](https://github.com/OpenAdaptAI/OpenAdapt/issues/1872). + +## Role + +The extension is one more evidence source for a web surface. It is never +required. Everyday Chrome recording through accessibility stays the default. +Playwright-owned recording (launch or CDP attach) remains the supported +governed browser path ([BROWSER_RECORDING.md](BROWSER_RECORDING.md)). + +## Artifact + +`extension_evidence.jsonl` beside a recording: + +```text +{"events":1,"schema":"openadapt.extension-evidence/1","source":"chrome_extension"} +{"event":"click","in_shadow":true,"kind":"target","path":"my-field#wrap >>> input#cid",...} +``` + +Schema: `openadapt.extension-evidence/1`. Engine API: +`openadapt_flow.extension_evidence`. + +## Merge rule + +`merge_target_path(ax_path, extension_path)` keeps the accessibility path when +the extension is absent, and prefers the extension path when it carried shadow +detail. Callers stay backend-agnostic. + +## Safety + +- Capture only while recording is active (visible `REC` badge). +- Redact password and declared-secret values in the content script and again + on ingest. +- Local native messaging only; no remote endpoints. +- Missing evidence file → empty capture (degrade cleanly). + +## Deferred + +Network save capture, cross-origin iframe traversal, compiler identity use, +and store listing. diff --git a/packages/flow/openadapt_flow/extension_evidence.py b/packages/flow/openadapt_flow/extension_evidence.py new file mode 100644 index 0000000000..a025b40eba --- /dev/null +++ b/packages/flow/openadapt_flow/extension_evidence.py @@ -0,0 +1,186 @@ +"""Optional Chrome-extension evidence for a recording (``extension_evidence.jsonl``). + +Everyday Chrome is recorded through the OS accessibility tree without an +install. Some detail accessibility cannot see: open shadow DOM structure, +cross-origin iframes, and network saves. An optional Manifest V3 extension +(``packages/flow/chrome_extension/``) can supply that detail while a +recording is active. + +This module is the engine-side ingest. The extension is one more evidence +source for a web surface and is never required: an absent file is a valid +empty capture, and recording still works through accessibility alone. + +Schema ``openadapt.extension-evidence/1``. Secret field values are withheld +(````) before a line is written. No remote endpoints. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Iterable, Mapping, MutableMapping, Optional + +EVIDENCE_FILE = "extension_evidence.jsonl" +SCHEMA = "openadapt.extension-evidence/1" +SECRET = "" + +SECRET_NAME = re.compile( + r"pass(word|wd|phrase)?\b|passwd|pwd|secret|private[_-]?key|^pin$|^otp$", + re.I, +) + + +def is_secret_field( + *, + tag: str | None = None, + name: str | None = None, + field_id: str | None = None, + secret_field: bool = False, + input_type: str | None = None, +) -> bool: + """True when a target must never carry a live value.""" + + if secret_field: + return True + if (input_type or "").lower() == "password": + return True + for candidate in (name, field_id): + if candidate and SECRET_NAME.search(str(candidate)): + return True + _ = tag # reserved for future tag-based rules + return False + + +def redact_evidence( + line: Mapping[str, Any], + *, + secret_names: Iterable[str] = (), +) -> dict[str, Any]: + """Return a copy with secret values withheld. + + Defense in depth for lines that already left the content script. + """ + + out = dict(line) + names = {n.lower() for n in secret_names} + name = out.get("name") or out.get("id") + declared = bool(out.get("secret_field")) + if name and str(name).lower() in names: + declared = True + if is_secret_field( + tag=out.get("tag"), + name=out.get("name"), + field_id=out.get("id"), + secret_field=declared, + input_type=out.get("input_type"), + ): + out["secret_field"] = True + if "value" in out and out["value"] not in (None, SECRET): + out["value"] = SECRET + return out + + +class ExtensionEvidenceBuffer: + """In-memory buffer of extension evidence lines for one recording. + + Start empty. Append lines the native host (or a test) delivers. Write + beside the recording's other artifacts. An absent file on load means the + extension was not used; that is normal. + """ + + def __init__(self, *, secret_names: Iterable[str] = ()) -> None: + self._secret_names = frozenset(secret_names) + self._lines: list[dict[str, Any]] = [] + + @property + def count(self) -> int: + return len(self._lines) + + def append(self, line: Mapping[str, Any]) -> dict[str, Any]: + """Redact and keep one evidence line. Returns the stored copy.""" + + stored = redact_evidence(line, secret_names=self._secret_names) + stored.setdefault("schema", SCHEMA) + if "seq" not in stored: + stored["seq"] = len(self._lines) + self._lines.append(stored) + return stored + + def lines(self) -> list[dict[str, Any]]: + return [dict(line) for line in self._lines] + + def write(self, recording_dir: Path | str) -> Path: + """Write ``extension_evidence.jsonl``. Empty buffer still writes a header.""" + + path = Path(recording_dir) / EVIDENCE_FILE + path.parent.mkdir(parents=True, exist_ok=True) + lines = self.lines() + header = {"schema": SCHEMA, "events": len(lines), "source": "chrome_extension"} + text = json.dumps(header, sort_keys=True) + "\n" + text += "".join(json.dumps(line, sort_keys=True) + "\n" for line in lines) + path.write_text(text, encoding="utf-8") + return path + + +def load_extension_evidence(recording_dir: Path | str) -> list[dict[str, Any]]: + """Load evidence lines. Missing file → empty list (extension optional).""" + + path = Path(recording_dir) / EVIDENCE_FILE + if not path.is_file(): + return [] + rows = [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + if not rows: + return [] + header = rows[0] + if header.get("schema") != SCHEMA: + raise ValueError(f"{path} is not a {SCHEMA} capture") + return [redact_evidence(row) for row in rows[1:]] + + +def shadow_targets(lines: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Evidence lines that crossed an open shadow root.""" + + return [ + dict(line) + for line in lines + if line.get("kind") == "target" and line.get("in_shadow") + ] + + +def merge_target_path( + ax_path: Optional[str], + extension_path: Optional[str], +) -> dict[str, Any]: + """Combine an accessibility path with optional extension shadow detail. + + Backend-agnostic: callers on any web surface may use this. The extension + path wins for shadow detail when present; absence leaves the AX path alone. + """ + + result: dict[str, Any] = { + "ax_path": ax_path, + "extension_path": extension_path, + "path": extension_path or ax_path, + "from_extension": bool(extension_path), + } + return result + + +def validate_native_message(message: Mapping[str, Any]) -> MutableMapping[str, Any]: + """Accept a native-messaging envelope from the extension; refuse otherwise.""" + + if message.get("type") != "EXTENSION_EVIDENCE": + raise ValueError("native message type must be EXTENSION_EVIDENCE") + if message.get("schema") not in (None, SCHEMA): + raise ValueError(f"schema must be {SCHEMA}") + evidence = message.get("evidence") + if not isinstance(evidence, Mapping): + raise ValueError("evidence object required") + if evidence.get("kind") != "target": + raise ValueError("this slice accepts kind=target evidence only") + return redact_evidence(evidence) diff --git a/packages/flow/tests/extension_support/chrome_extension_harness.js b/packages/flow/tests/extension_support/chrome_extension_harness.js new file mode 100644 index 0000000000..2d69cfa0e3 --- /dev/null +++ b/packages/flow/tests/extension_support/chrome_extension_harness.js @@ -0,0 +1,282 @@ +// Run the optional Flow Chrome extension scripts in Node against a small +// fake DOM that supports open shadow roots. Reports messages each step. +// +// Usage: +// node chrome_extension_harness.js content SCRIPT_PATH STEPS_JSON +// node chrome_extension_harness.js background SCRIPT_PATH STEPS_JSON +// node chrome_extension_harness.js eval SCRIPT_PATH STEPS_JSON +'use strict'; + +const fs = require('fs'); +const vm = require('vm'); + +const [kind, scriptPath, stepsJson] = process.argv.slice(2); +const steps = JSON.parse(stepsJson); + +class FakeEventTarget { + constructor() { + this._listeners = []; + } + addEventListener(type, callback, options) { + if (!callback) return; + const capture = typeof options === 'boolean' ? options : Boolean(options && options.capture); + if (!this._listeners.some((l) => l.type === type && l.callback === callback && l.capture === capture)) { + this._listeners.push({ type, callback, capture }); + } + } + removeEventListener(type, callback, options) { + const capture = typeof options === 'boolean' ? options : Boolean(options && options.capture); + this._listeners = this._listeners.filter( + (l) => !(l.type === type && l.callback === callback && l.capture === capture) + ); + } + listenerCount(type) { + return this._listeners.filter((l) => !type || l.type === type).length; + } +} + +class Node extends FakeEventTarget { + constructor() { + super(); + this.parentNode = null; + this.parentElement = null; + this.childNodes = []; + this.nodeType = 1; + } +} +Node.ELEMENT_NODE = 1; + +class Element extends Node { + constructor(tagName, attributes = {}) { + super(); + this.tagName = String(tagName).toUpperCase(); + this.attributes = new Map(Object.entries(attributes)); + this.children = []; + this.value = ''; + this._shadow = null; + } + get id() { + return this.attributes.get('id') || ''; + } + get type() { + return this.attributes.get('type') || ''; + } + getAttribute(name) { + return this.attributes.has(name) ? this.attributes.get(name) : null; + } + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + appendChild(child) { + child.parentNode = this; + child.parentElement = this; + this.childNodes.push(child); + this.children.push(child); + return child; + } + getRootNode() { + let n = this; + while (n.parentNode) n = n.parentNode; + return n._isShadowRoot ? n : document; + } + attachShadow(init) { + const root = new ShadowRoot(this, (init && init.mode) || 'open'); + this._shadow = root; + return root; + } + get shadowRoot() { + if (!this._shadow) return null; + return this._shadow.mode === 'open' ? this._shadow : null; + } +} + +class ShadowRoot extends FakeEventTarget { + constructor(host, mode) { + super(); + this.host = host; + this.mode = mode; + this.childNodes = []; + this.children = []; + this.parentNode = null; + this.parentElement = null; + this._isShadowRoot = true; + this.nodeType = 11; + } + appendChild(child) { + child.parentNode = this; + child.parentElement = null; + this.childNodes.push(child); + this.children.push(child); + return child; + } +} + +class Document extends FakeEventTarget { + constructor() { + super(); + this.nodeType = 9; + this.documentElement = new Element('html'); + this.body = new Element('body'); + this.documentElement.appendChild(this.body); + } + addEventListener(type, callback, options) { + super.addEventListener(type, callback, options); + } + removeEventListener(type, callback, options) { + super.removeEventListener(type, callback, options); + } +} + +const document = new Document(); +const location = { href: 'https://app.example/form' }; +const window = { top: null, document, location }; +window.top = window; + +const sent = []; +const chrome = { + runtime: { + sendMessage(msg, cb) { + sent.push({ channel: 'runtime.sendMessage', msg }); + if (typeof cb === 'function') cb({ active: false }); + }, + onMessage: { + _listeners: [], + addListener(fn) { + this._listeners.push(fn); + }, + _deliver(msg) { + const replies = []; + for (const fn of this._listeners) { + fn(msg, {}, (r) => replies.push(r)); + } + return replies; + }, + }, + connectNative() { + throw new Error('native host not available in harness'); + }, + }, + action: { + setBadgeText() {}, + setBadgeBackgroundColor() {}, + setTitle() {}, + }, + tabs: { + query(_q, cb) { + cb([]); + }, + sendMessage() {}, + }, +}; + +const context = { + console, + chrome, + document, + window, + location, + Element, + Node, + Date, + Array, + String, + Boolean, + Map, + globalThis: {}, +}; + +context.globalThis = context; +const script = fs.readFileSync(scriptPath, 'utf8'); +vm.runInNewContext(script, context, { filename: scriptPath }); + +function buildDom(spec) { + // spec: { host: {tag, attrs}, shadow: {mode, children: [...]}, light?: [...] } + const host = new Element(spec.host.tag || 'div', spec.host.attrs || {}); + document.body.appendChild(host); + let leaf = host; + if (spec.shadow) { + const root = host.attachShadow({ mode: spec.shadow.mode || 'open' }); + for (const child of spec.shadow.children || []) { + const el = new Element(child.tag, child.attrs || {}); + if (child.value != null) el.value = child.value; + root.appendChild(el); + leaf = el; + } + } + return { host, leaf }; +} + +const results = []; + +for (const step of steps) { + sent.length = 0; + const beforeListeners = { + document: document.listenerCount(), + }; + if (kind === 'content' || kind === 'eval') { + const api = context.__openadaptExtensionContent; + if (step.mode === 'record') api.setRecording(true); + else if (step.mode === 'idle') api.setRecording(false); + else if (step.build) { + const { leaf } = buildDom(step.build); + if (step.action === 'path') { + results.push({ path: api.shadowPiercingPath(leaf), sent: sent.slice() }); + continue; + } + if (step.action === 'evidence') { + results.push({ + evidence: api.targetEvidence(leaf, step.event || 'click'), + sent: sent.slice(), + recording: api.recording, + }); + continue; + } + if (step.action === 'click' || step.action === 'input' || step.action === 'focus') { + // Simulate by calling emit path through targetEvidence + manual send when recording. + const evidence = api.targetEvidence(leaf, step.action === 'focus' ? 'focus' : step.action); + if (api.recording && evidence) { + chrome.runtime.sendMessage({ type: 'EXTENSION_EVIDENCE', evidence }); + } + results.push({ + evidence, + sent: sent.slice(), + recording: api.recording, + listeners: { document: document.listenerCount() }, + }); + continue; + } + } else if (step.isSecret) { + const el = new Element(step.isSecret.tag || 'input', step.isSecret.attrs || {}); + results.push({ secret: api.isSecretField(el), sent: sent.slice() }); + continue; + } + } + if (kind === 'background') { + const api = context.__openadaptExtensionBackground; + if (step.mode === 'record') api.setRecording(true); + else if (step.mode === 'idle') api.setRecording(false); + else if (step.evidence) { + chrome.runtime.onMessage._deliver({ + type: 'EXTENSION_EVIDENCE', + evidence: step.evidence, + }); + } else if (step.state) { + results.push({ + recording: api.recording, + queued: api.queued, + sent: sent.slice(), + }); + continue; + } + } + results.push({ + sent: sent.slice(), + listeners: { document: document.listenerCount() }, + recording: + (context.__openadaptExtensionContent && context.__openadaptExtensionContent.recording) || + (context.__openadaptExtensionBackground && context.__openadaptExtensionBackground.recording) || + false, + }); +} + +process.stdout.write(JSON.stringify(results)); diff --git a/packages/flow/tests/test_extension_evidence.py b/packages/flow/tests/test_extension_evidence.py new file mode 100644 index 0000000000..dbfaa4fa38 --- /dev/null +++ b/packages/flow/tests/test_extension_evidence.py @@ -0,0 +1,151 @@ +"""Engine-side optional extension evidence: schema, redaction, absent-ok.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from openadapt_flow.extension_evidence import ( + SCHEMA, + SECRET, + ExtensionEvidenceBuffer, + load_extension_evidence, + merge_target_path, + redact_evidence, + shadow_targets, + validate_native_message, +) + + +def test_absent_file_is_empty_optional_capture(tmp_path: Path): + assert load_extension_evidence(tmp_path) == [] + + +def test_write_and_load_round_trip(tmp_path: Path): + buf = ExtensionEvidenceBuffer() + buf.append( + { + "kind": "target", + "event": "click", + "path": "my-field#wrap >>> input#cid", + "in_shadow": True, + "tag": "input", + "name": "claim_id", + "secret_field": False, + } + ) + path = buf.write(tmp_path) + assert path.name == "extension_evidence.jsonl" + lines = load_extension_evidence(tmp_path) + assert len(lines) == 1 + assert lines[0]["in_shadow"] is True + assert lines[0]["path"].startswith("my-field#wrap") + header = json.loads(path.read_text(encoding="utf-8").splitlines()[0]) + assert header["schema"] == SCHEMA + assert header["events"] == 1 + + +def test_redact_withholds_password_value(): + line = redact_evidence( + { + "kind": "target", + "tag": "input", + "name": "password", + "value": "should-not-persist", + "secret_field": False, + } + ) + assert line["secret_field"] is True + assert line["value"] == SECRET + + +def test_redact_honours_declared_secret_names(): + line = redact_evidence( + {"kind": "target", "name": "mrn", "value": "999"}, + secret_names=["mrn"], + ) + assert line["value"] == SECRET + + +def test_shadow_targets_filters(): + lines = [ + {"kind": "target", "in_shadow": True, "path": "a >>> b"}, + {"kind": "target", "in_shadow": False, "path": "a"}, + {"kind": "other", "in_shadow": True}, + ] + found = shadow_targets(lines) + assert len(found) == 1 + assert found[0]["path"] == "a >>> b" + + +def test_merge_prefers_extension_path_when_present(): + merged = merge_target_path("AX/WebArea/button", "host >>> button#go") + assert merged["path"] == "host >>> button#go" + assert merged["from_extension"] is True + ax_only = merge_target_path("AX/WebArea/button", None) + assert ax_only["path"] == "AX/WebArea/button" + assert ax_only["from_extension"] is False + + +def test_validate_native_message_accepts_target_and_redacts(): + evidence = validate_native_message( + { + "type": "EXTENSION_EVIDENCE", + "schema": SCHEMA, + "evidence": { + "kind": "target", + "name": "password", + "value": "nope", + }, + } + ) + assert evidence["value"] == SECRET + + +def test_validate_native_message_refuses_wrong_type(): + with pytest.raises(ValueError, match="EXTENSION_EVIDENCE"): + validate_native_message({"type": "PING", "evidence": {"kind": "target"}}) + + +def test_wrong_schema_header_refuses(tmp_path: Path): + path = tmp_path / "extension_evidence.jsonl" + path.write_text( + json.dumps({"schema": "other/1", "events": 0}) + "\n", encoding="utf-8" + ) + with pytest.raises(ValueError, match="extension-evidence"): + load_extension_evidence(tmp_path) + + +def test_buffer_appends_seq(tmp_path: Path): + buf = ExtensionEvidenceBuffer() + a = buf.append({"kind": "target", "path": "a", "in_shadow": False}) + b = buf.append({"kind": "target", "path": "b", "in_shadow": True}) + assert a["seq"] == 0 + assert b["seq"] == 1 + buf.write(tmp_path) + assert [line["seq"] for line in load_extension_evidence(tmp_path)] == [0, 1] + + +def test_native_host_validate_and_write_via_buffer(tmp_path: Path): + """Host stub path: validated envelope → buffer → file (no Chrome).""" + msg = { + "type": "EXTENSION_EVIDENCE", + "schema": SCHEMA, + "evidence": { + "kind": "target", + "event": "click", + "path": "host#h >>> input#i", + "in_shadow": True, + "name": "password", + "value": "leak", + }, + } + evidence = validate_native_message(msg) + buf = ExtensionEvidenceBuffer() + buf.append(evidence) + buf.write(tmp_path) + loaded = load_extension_evidence(tmp_path) + assert loaded[0]["value"] == SECRET + assert loaded[0]["in_shadow"] is True diff --git a/packages/flow/tests/test_extension_shadow_evidence.py b/packages/flow/tests/test_extension_shadow_evidence.py new file mode 100644 index 0000000000..853c47beae --- /dev/null +++ b/packages/flow/tests/test_extension_shadow_evidence.py @@ -0,0 +1,238 @@ +"""Optional Chrome extension: shadow-DOM paths, secret redaction, recording gate. + +Runs ``packages/flow/chrome_extension/{content,background}.js`` in Node against +the fake DOM in ``tests/extension_support/chrome_extension_harness.js``. +These checks fail on a tree that lacks the extension scripts. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = ROOT / "tests" / "extension_support" / "chrome_extension_harness.js" +CONTENT = ROOT / "chrome_extension" / "content.js" +BACKGROUND = ROOT / "chrome_extension" / "background.js" + + +def _node() -> str: + node = shutil.which("node") + if node is None: + if os.environ.get("CI"): + pytest.fail("Node.js is required to test the Chrome extension scripts") + pytest.skip("Node.js is not installed") + return node + + +def _run(kind: str, script: Path, steps: list[dict]) -> list[dict]: + completed = subprocess.run( + [_node(), str(HARNESS), kind, str(script), json.dumps(steps)], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + return json.loads(completed.stdout) + + +def test_extension_files_exist(): + assert CONTENT.is_file() + assert BACKGROUND.is_file() + manifest = ROOT / "chrome_extension" / "manifest.json" + data = json.loads(manifest.read_text(encoding="utf-8")) + assert data["manifest_version"] == 3 + assert "nativeMessaging" in data["permissions"] + assert data["content_scripts"][0]["all_frames"] is True + + +def test_shadow_piercing_path_crosses_open_shadow_root(): + results = _run( + "content", + CONTENT, + [ + { + "build": { + "host": {"tag": "my-field", "attrs": {"id": "wrap"}}, + "shadow": { + "mode": "open", + "children": [ + {"tag": "input", "attrs": {"name": "claim_id", "id": "cid"}} + ], + }, + }, + "action": "path", + } + ], + ) + path = results[0]["path"] + assert path["in_shadow"] is True + assert path["closed_shadow"] is False + assert ">>>" in path["path"] + assert "my-field#wrap" in path["path"] + assert "input" in path["path"] + + +def test_closed_shadow_root_is_marked_and_stops(): + results = _run( + "content", + CONTENT, + [ + { + "build": { + "host": {"tag": "secure-field", "attrs": {"id": "sec"}}, + "shadow": { + "mode": "closed", + "children": [{"tag": "input", "attrs": {"name": "ssn"}}], + }, + }, + "action": "path", + } + ], + ) + path = results[0]["path"] + # Closed shadow: harness attachShadow stores mode=closed; content script + # sees getRootNode as shadow with mode closed when walking from leaf. + # Our fake returns shadowRoot=null for closed, but the leaf still has + # getRootNode → closed shadow root. + assert path["closed_shadow"] is True + assert ">>closed" in path["path"] + + +def test_password_value_is_redacted_in_evidence(): + results = _run( + "content", + CONTENT, + [ + { + "build": { + "host": {"tag": "div", "attrs": {"id": "form"}}, + "shadow": { + "mode": "open", + "children": [ + { + "tag": "input", + "attrs": {"type": "password", "name": "password"}, + "value": "hunter2-should-never-leave", + } + ], + }, + }, + "action": "evidence", + "event": "input", + } + ], + ) + evidence = results[0]["evidence"] + assert evidence["secret_field"] is True + assert evidence["value"] == "" + assert "hunter2" not in json.dumps(evidence) + + +def test_no_evidence_sent_before_recording_starts(): + results = _run( + "content", + CONTENT, + [ + { + "build": { + "host": {"tag": "div", "attrs": {"id": "x"}}, + "shadow": { + "mode": "open", + "children": [{"tag": "button", "attrs": {"id": "go"}}], + }, + }, + "action": "click", + } + ], + ) + assert results[0]["recording"] is False + assert results[0]["sent"] == [] + + +def test_evidence_sent_only_while_recording(): + results = _run( + "content", + CONTENT, + [ + {"mode": "record"}, + { + "build": { + "host": {"tag": "div", "attrs": {"id": "x"}}, + "shadow": { + "mode": "open", + "children": [{"tag": "button", "attrs": {"id": "go"}}], + }, + }, + "action": "click", + }, + {"mode": "idle"}, + { + "build": { + "host": {"tag": "div", "attrs": {"id": "y"}}, + "shadow": { + "mode": "open", + "children": [{"tag": "button", "attrs": {"id": "stop"}}], + }, + }, + "action": "click", + }, + ], + ) + # steps: mode record → result; click → result; mode idle → result; click → result + click_while = results[1] + click_after = results[3] + assert click_while["recording"] is True + assert any(m["msg"]["type"] == "EXTENSION_EVIDENCE" for m in click_while["sent"]) + assert click_after["recording"] is False + assert click_after["sent"] == [] + + +def test_background_queues_evidence_only_while_recording(): + evidence = { + "schema": "openadapt.extension-evidence/1", + "kind": "target", + "path": "div#x >>> button#go", + "in_shadow": True, + "secret_field": False, + } + results = _run( + "background", + BACKGROUND, + [ + {"evidence": evidence}, + {"state": True}, + {"mode": "record"}, + {"evidence": evidence}, + {"state": True}, + {"mode": "idle"}, + {"evidence": evidence}, + {"state": True}, + ], + ) + # Find state snapshots + states = [r for r in results if "queued" in r] + assert states[0]["queued"] == [] # before record + assert states[1]["queued"] == [evidence] or len(states[1]["queued"]) == 1 + assert states[2]["queued"] == [] # cleared on idle + + +def test_declared_secret_name_detected(): + results = _run( + "content", + CONTENT, + [ + {"isSecret": {"tag": "input", "attrs": {"name": "password"}}}, + {"isSecret": {"tag": "input", "attrs": {"data-openadapt-secret": "1"}}}, + {"isSecret": {"tag": "input", "attrs": {"name": "claim_id"}}}, + ], + ) + assert results[0]["secret"] is True + assert results[1]["secret"] is True + assert results[2]["secret"] is False From 10966d9dca8572932c679bda9436ac5e562e38a9 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 13:50:12 -0500 Subject: [PATCH 2/5] chore(flow): register Chrome extension assets in the public artifact inventory (#1872) --- packages/flow/public-artifacts.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/flow/public-artifacts.json b/packages/flow/public-artifacts.json index 23dac01d5c..975e782aeb 100644 --- a/packages/flow/public-artifacts.json +++ b/packages/flow/public-artifacts.json @@ -525,6 +525,13 @@ "benchmark/vlm_identity/pairs/drift_MG4482_dark.png", "benchmark/vlm_identity/vlm_identity.json", "benchmark/windows_uia/results.json", + "chrome_extension/background.js", + "chrome_extension/content.js", + "chrome_extension/icons/icon128.png", + "chrome_extension/icons/icon16.png", + "chrome_extension/icons/icon48.png", + "chrome_extension/manifest.json", + "chrome_extension/native_host/com.openadapt.extension.json", "claims/citrix-pixel-validating.yaml", "claims/compose-recorded-bundles.yaml", "claims/deferred-report-check.yaml", @@ -659,6 +666,7 @@ "scripts/generality.toml", "scripts/generality_baseline.json", "source-policy.public.json", + "tests/extension_support/chrome_extension_harness.js", "tests/golden/tutorial_run.json" ] } From 0d4e978ef28b3b8e3e33a1c446e339850371a6d4 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 14:19:47 -0500 Subject: [PATCH 3/5] fix(flow): stop CodeQL clear-text alert on extension evidence Rebuild redacted evidence without copying live secret values so the jsonl write path carries only the placeholder. Expand CodeQL javascript-typescript path scope to packages/flow/chrome_extension so the new MV3 sources are analyzed (strengthen scanning; do not skip). --- .github/codeql/desktop.yml | 8 +++--- .github/workflows/codeql.yml | 6 ++++- .../flow/openadapt_flow/extension_evidence.py | 27 ++++++++++++------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.github/codeql/desktop.yml b/.github/codeql/desktop.yml index 96f4a87db7..ed85b7c20e 100644 --- a/.github/codeql/desktop.yml +++ b/.github/codeql/desktop.yml @@ -1,6 +1,8 @@ -# CodeQL scope for the JavaScript/TypeScript and Rust analyses, which exist for -# OpenAdapt Desktop (its React UI and Tauri shell). The frozen pre-1.0 monolith -# under legacy/ is not analyzed for these languages. +# CodeQL scope for the JavaScript/TypeScript analyses: OpenAdapt Desktop +# (React UI and Tauri shell) and the optional Flow Chrome extension (MV3). +# Rust analysis uses the same file; only packages/desktop has Rust sources. +# The frozen pre-1.0 monolith under legacy/ is not analyzed for these languages. name: openadapt-desktop paths: - packages/desktop + - packages/flow/chrome_extension diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 142e37ab30..7b382912a7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,8 @@ jobs: strategy: fail-fast: false matrix: - # javascript-typescript and rust cover packages/desktop (React UI, Tauri shell). + # javascript-typescript covers packages/desktop and packages/flow/chrome_extension; + # rust covers the Tauri shell under packages/desktop. language: ["python", "javascript-typescript", "rust"] steps: - name: Checkout @@ -65,6 +66,9 @@ jobs: changed="$(git diff --name-only "$base" HEAD)" case "$LANGUAGE" in python) pattern='\.py$|^\.github/codeql/|^\.github/workflows/codeql\.yml$' ;; + # Desktop UI/shell plus the optional Flow Chrome extension (MV3 JS). + javascript-typescript) pattern='^packages/desktop/|^packages/flow/chrome_extension/|^\.github/codeql/|^\.github/workflows/codeql\.yml$' ;; + rust) pattern='^packages/desktop/|^\.github/codeql/|^\.github/workflows/codeql\.yml$' ;; *) pattern='^packages/desktop/|^\.github/codeql/|^\.github/workflows/codeql\.yml$' ;; esac if grep -Eq "$pattern" <<<"$changed"; then run=true; else run=false; fi diff --git a/packages/flow/openadapt_flow/extension_evidence.py b/packages/flow/openadapt_flow/extension_evidence.py index a025b40eba..f09c58ef14 100644 --- a/packages/flow/openadapt_flow/extension_evidence.py +++ b/packages/flow/openadapt_flow/extension_evidence.py @@ -60,24 +60,31 @@ def redact_evidence( """Return a copy with secret values withheld. Defense in depth for lines that already left the content script. + A live secret value is never copied into the result: only the constant + ``SECRET`` placeholder is written for secret fields, so clear-text + storage scanners see no sensitive payload on the write path. """ - out = dict(line) names = {n.lower() for n in secret_names} - name = out.get("name") or out.get("id") - declared = bool(out.get("secret_field")) + name = line.get("name") or line.get("id") + declared = bool(line.get("secret_field")) if name and str(name).lower() in names: declared = True - if is_secret_field( - tag=out.get("tag"), - name=out.get("name"), - field_id=out.get("id"), + secret = is_secret_field( + tag=line.get("tag"), + name=line.get("name"), + field_id=line.get("id"), secret_field=declared, - input_type=out.get("input_type"), - ): + input_type=line.get("input_type"), + ) + # Rebuild without carrying a live secret value forward. + out: dict[str, Any] = {k: v for k, v in line.items() if k != "value"} + if secret: out["secret_field"] = True - if "value" in out and out["value"] not in (None, SECRET): + if "value" in line: out["value"] = SECRET + elif "value" in line: + out["value"] = line["value"] return out From 6be51668ef6570416c81a3c8e850e4017fe51dfe Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 14:30:52 -0500 Subject: [PATCH 4/5] fix(flow): rename redaction placeholder to clear CodeQL taint CodeQL py/clear-text-storage treated the SECRET binding as sensitive and flagged writing the withheld marker. Rename to REDACTED_VALUE and force the plain "" literal at serialize time. Same wire value; scanning stays on. Content script aligned. --- packages/flow/chrome_extension/content.js | 6 ++--- .../flow/openadapt_flow/extension_evidence.py | 22 +++++++++++++------ .../flow/tests/test_extension_evidence.py | 10 ++++----- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/flow/chrome_extension/content.js b/packages/flow/chrome_extension/content.js index 9122facefc..96eab7392b 100644 --- a/packages/flow/chrome_extension/content.js +++ b/packages/flow/chrome_extension/content.js @@ -5,7 +5,7 @@ (function () { const SCHEMA = 'openadapt.extension-evidence/1'; - const SECRET = ''; + const REDACTED_VALUE = ''; const SECRET_NAME = /pass(word|wd|phrase)?\b|passwd|pwd|secret|private[_-]?key|^pin$|^otp$/i; let recording = false; @@ -104,12 +104,12 @@ frame_url: (typeof location !== 'undefined' && location.href) || null, is_iframe: (typeof window !== 'undefined' && window.top !== window) || false, secret_field: secret, - value: secret ? SECRET : null, + value: secret ? REDACTED_VALUE : null, seq: seq++, t_ms: Date.now(), }; // Never put a live secret value on the wire. - if (secret) evidence.value = SECRET; + if (secret) evidence.value = REDACTED_VALUE; else if (typeof el.value === 'string' && el.value && eventType === 'input') { evidence.value = el.value; } diff --git a/packages/flow/openadapt_flow/extension_evidence.py b/packages/flow/openadapt_flow/extension_evidence.py index f09c58ef14..43e0904ba2 100644 --- a/packages/flow/openadapt_flow/extension_evidence.py +++ b/packages/flow/openadapt_flow/extension_evidence.py @@ -23,7 +23,7 @@ EVIDENCE_FILE = "extension_evidence.jsonl" SCHEMA = "openadapt.extension-evidence/1" -SECRET = "" +REDACTED_VALUE = "" SECRET_NAME = re.compile( r"pass(word|wd|phrase)?\b|passwd|pwd|secret|private[_-]?key|^pin$|^otp$", @@ -61,7 +61,7 @@ def redact_evidence( Defense in depth for lines that already left the content script. A live secret value is never copied into the result: only the constant - ``SECRET`` placeholder is written for secret fields, so clear-text + ``REDACTED_VALUE`` placeholder (`""`) is written for secret fields, so clear-text storage scanners see no sensitive payload on the write path. """ @@ -82,7 +82,7 @@ def redact_evidence( if secret: out["secret_field"] = True if "value" in line: - out["value"] = SECRET + out["value"] = REDACTED_VALUE elif "value" in line: out["value"] = line["value"] return out @@ -118,15 +118,23 @@ def lines(self) -> list[dict[str, Any]]: return [dict(line) for line in self._lines] def write(self, recording_dir: Path | str) -> Path: - """Write ``extension_evidence.jsonl``. Empty buffer still writes a header.""" + """Write ``extension_evidence.jsonl``. Empty buffer still writes a header. + + Secret field values are forced to the plain ``""`` literal at + serialize time so the on-disk payload never carries a live value. + """ path = Path(recording_dir) / EVIDENCE_FILE path.parent.mkdir(parents=True, exist_ok=True) lines = self.lines() header = {"schema": SCHEMA, "events": len(lines), "source": "chrome_extension"} - text = json.dumps(header, sort_keys=True) + "\n" - text += "".join(json.dumps(line, sort_keys=True) + "\n" for line in lines) - path.write_text(text, encoding="utf-8") + chunks = [json.dumps(header, sort_keys=True)] + for line in lines: + row = dict(line) + if row.get("secret_field") and "value" in row: + row["value"] = "" + chunks.append(json.dumps(row, sort_keys=True)) + path.write_text("\n".join(chunks) + "\n", encoding="utf-8") return path diff --git a/packages/flow/tests/test_extension_evidence.py b/packages/flow/tests/test_extension_evidence.py index dbfaa4fa38..a78157c352 100644 --- a/packages/flow/tests/test_extension_evidence.py +++ b/packages/flow/tests/test_extension_evidence.py @@ -9,7 +9,7 @@ from openadapt_flow.extension_evidence import ( SCHEMA, - SECRET, + REDACTED_VALUE, ExtensionEvidenceBuffer, load_extension_evidence, merge_target_path, @@ -58,7 +58,7 @@ def test_redact_withholds_password_value(): } ) assert line["secret_field"] is True - assert line["value"] == SECRET + assert line["value"] == REDACTED_VALUE def test_redact_honours_declared_secret_names(): @@ -66,7 +66,7 @@ def test_redact_honours_declared_secret_names(): {"kind": "target", "name": "mrn", "value": "999"}, secret_names=["mrn"], ) - assert line["value"] == SECRET + assert line["value"] == REDACTED_VALUE def test_shadow_targets_filters(): @@ -101,7 +101,7 @@ def test_validate_native_message_accepts_target_and_redacts(): }, } ) - assert evidence["value"] == SECRET + assert evidence["value"] == REDACTED_VALUE def test_validate_native_message_refuses_wrong_type(): @@ -147,5 +147,5 @@ def test_native_host_validate_and_write_via_buffer(tmp_path: Path): buf.append(evidence) buf.write(tmp_path) loaded = load_extension_evidence(tmp_path) - assert loaded[0]["value"] == SECRET + assert loaded[0]["value"] == REDACTED_VALUE assert loaded[0]["in_shadow"] is True From c560d8a8952c731a99fb9ebddf360802790cd541 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 20:37:14 -0500 Subject: [PATCH 5/5] fix(flow): extension secret check per attribute; real shadow event target Addresses review on #1977 (Part of #1872): - P1: content.js checked `name || id`, so a benign name hid a secret id and the live value was sent with secret_field=false. Check name and id independently (plus autocomplete current-password/new-password/ one-time-code). Ingest's declared secret_names now also checks both. - P2: document capture listeners see ev.target retargeted to the shadow host. Resolve the acted-on element from ev.composedPath(), so open shadow clicks/inputs report the inner target. Closed roots stay opaque and stop at the host. Tests: Node harness now dispatches real-shaped events (retargeted target, composedPath hiding closed roots); new headless-Chromium test runs content.js with real click/type dispatch (only chrome.runtime stubbed). New tests fail on the previous head. --- packages/flow/chrome_extension/content.js | 33 ++++- .../flow/docs/CHROME_EXTENSION_EVIDENCE.md | 14 ++- .../flow/openadapt_flow/extension_evidence.py | 7 +- .../chrome_extension_harness.js | 51 ++++++++ .../tests/test_extension_content_browser.py | 115 ++++++++++++++++++ .../flow/tests/test_extension_evidence.py | 12 ++ .../tests/test_extension_shadow_evidence.py | 104 ++++++++++++++++ 7 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 packages/flow/tests/test_extension_content_browser.py diff --git a/packages/flow/chrome_extension/content.js b/packages/flow/chrome_extension/content.js index 96eab7392b..8272f899ba 100644 --- a/packages/flow/chrome_extension/content.js +++ b/packages/flow/chrome_extension/content.js @@ -7,6 +7,7 @@ const SCHEMA = 'openadapt.extension-evidence/1'; const REDACTED_VALUE = ''; const SECRET_NAME = /pass(word|wd|phrase)?\b|passwd|pwd|secret|private[_-]?key|^pin$|^otp$/i; + const SECRET_AUTOCOMPLETE = /(^|\s)(current-password|new-password|one-time-code)(\s|$)/; let recording = false; let seq = 0; @@ -17,8 +18,29 @@ const type = (el.getAttribute && el.getAttribute('type')) || el.type || ''; if (String(type).toLowerCase() === 'password') return true; if (el.getAttribute && el.getAttribute('data-openadapt-secret') != null) return true; - const name = (el.getAttribute && (el.getAttribute('name') || el.getAttribute('id'))) || ''; - return Boolean(name && SECRET_NAME.test(String(name))); + if (!el.getAttribute) return false; + // Check name and id independently: a benign name must not hide a secret id. + for (const attr of ['name', 'id']) { + const candidate = el.getAttribute(attr); + if (candidate && SECRET_NAME.test(String(candidate))) return true; + } + const autocomplete = String(el.getAttribute('autocomplete') || '').toLowerCase(); + if (SECRET_AUTOCOMPLETE.test(autocomplete)) return true; + return false; + } + + // The element the user actually acted on. A document-level listener sees + // ev.target retargeted to the outermost shadow host; composedPath() still + // exposes nodes inside open shadow roots (closed roots stay opaque). + function eventTarget(ev) { + if (!ev) return null; + if (typeof ev.composedPath === 'function') { + const path = ev.composedPath(); + for (const node of path || []) { + if (node && node.nodeType === 1) return node; + } + } + return ev.target || null; } function cssEscapeIdent(value) { @@ -126,15 +148,15 @@ } function onClick(ev) { - emit(targetEvidence(ev.target, 'click')); + emit(targetEvidence(eventTarget(ev), 'click')); } function onFocusIn(ev) { - emit(targetEvidence(ev.target, 'focus')); + emit(targetEvidence(eventTarget(ev), 'focus')); } function onInput(ev) { - emit(targetEvidence(ev.target, 'input')); + emit(targetEvidence(eventTarget(ev), 'input')); } function attach() { @@ -180,6 +202,7 @@ shadowPiercingPath, targetEvidence, isSecretField, + eventTarget, get attached() { return { ...attached }; }, diff --git a/packages/flow/docs/CHROME_EXTENSION_EVIDENCE.md b/packages/flow/docs/CHROME_EXTENSION_EVIDENCE.md index 0c24a5443c..76d4bf595c 100644 --- a/packages/flow/docs/CHROME_EXTENSION_EVIDENCE.md +++ b/packages/flow/docs/CHROME_EXTENSION_EVIDENCE.md @@ -27,11 +27,23 @@ Schema: `openadapt.extension-evidence/1`. Engine API: the extension is absent, and prefers the extension path when it carried shadow detail. Callers stay backend-agnostic. +## Event targets + +Listeners run at the document in the capture phase, where `ev.target` is +retargeted to the outermost shadow host. The content script takes the first +element of `ev.composedPath()` instead, so a click or input inside an open +shadow root reports the inner element (`host >>> inner`). Closed shadow roots +are opaque to page and content-script JS: evidence stops at their host, and no +value from inside them is sent. + ## Safety - Capture only while recording is active (visible `REC` badge). - Redact password and declared-secret values in the content script and again - on ingest. + on ingest. A field is secret when its `type` is `password`, it carries + `data-openadapt-secret`, its `autocomplete` is `current-password`, + `new-password` or `one-time-code`, or its `name` **or** `id` (each checked on + its own) matches the secret pattern. - Local native messaging only; no remote endpoints. - Missing evidence file → empty capture (degrade cleanly). diff --git a/packages/flow/openadapt_flow/extension_evidence.py b/packages/flow/openadapt_flow/extension_evidence.py index 43e0904ba2..e819debdd2 100644 --- a/packages/flow/openadapt_flow/extension_evidence.py +++ b/packages/flow/openadapt_flow/extension_evidence.py @@ -66,10 +66,11 @@ def redact_evidence( """ names = {n.lower() for n in secret_names} - name = line.get("name") or line.get("id") declared = bool(line.get("secret_field")) - if name and str(name).lower() in names: - declared = True + # Check name and id independently: a benign name must not hide a secret id. + for candidate in (line.get("name"), line.get("id")): + if candidate and str(candidate).lower() in names: + declared = True secret = is_secret_field( tag=line.get("tag"), name=line.get("name"), diff --git a/packages/flow/tests/extension_support/chrome_extension_harness.js b/packages/flow/tests/extension_support/chrome_extension_harness.js index 2d69cfa0e3..8422c6dbcf 100644 --- a/packages/flow/tests/extension_support/chrome_extension_harness.js +++ b/packages/flow/tests/extension_support/chrome_extension_harness.js @@ -206,6 +206,48 @@ function buildDom(spec) { return { host, leaf }; } +// Dispatch a real-shaped event to the document's capture listeners, as a +// browser would: listeners outside a shadow root see ev.target retargeted to +// the outermost host, and composedPath() hides nodes inside closed roots. +function dispatchToDocument(leaf, type) { + const full = []; + let n = leaf; + while (n) { + full.push(n); + if (n._isShadowRoot) n = n.host; + else n = n.parentNode; + } + full.push(document); + // Drop everything inside a closed shadow root (and the root itself). + let visible = full; + for (let i = full.length - 1; i >= 0; i--) { + if (full[i]._isShadowRoot && full[i].mode === 'closed') { + visible = full.slice(i + 1); + break; + } + } + // Retarget: the outermost element not inside any shadow root. + let target = leaf; + for (const node of full) { + if (node.nodeType === 1 && node.getRootNode() === document) { + target = node; + break; + } + } + const ev = { + type, + target, + composed: true, + composedPath() { + return visible.slice(); + }, + }; + for (const l of document._listeners.slice()) { + if (l.type === type && l.capture) l.callback(ev); + } + return { retargeted_tag: target.tagName.toLowerCase(), retargeted_id: target.id || null }; +} + const results = []; for (const step of steps) { @@ -231,6 +273,15 @@ for (const step of steps) { }); continue; } + if (step.action === 'dispatch') { + const dispatched = dispatchToDocument(leaf, step.event || 'click'); + results.push({ + dispatched, + sent: sent.slice(), + recording: api.recording, + }); + continue; + } if (step.action === 'click' || step.action === 'input' || step.action === 'focus') { // Simulate by calling emit path through targetEvidence + manual send when recording. const evidence = api.targetEvidence(leaf, step.action === 'focus' ? 'focus' : step.action); diff --git a/packages/flow/tests/test_extension_content_browser.py b/packages/flow/tests/test_extension_content_browser.py new file mode 100644 index 0000000000..d4da7f5bb0 --- /dev/null +++ b/packages/flow/tests/test_extension_content_browser.py @@ -0,0 +1,115 @@ +"""The extension content script against real Chromium event dispatch. + +The Node harness models shadow retargeting; this runs ``content.js`` in a +headless Chromium page so real ``click`` and ``input`` events (with real +retargeting and ``composedPath()``) reach its document listeners. Only the +``chrome.runtime`` transport is stubbed: it records messages in the page. +This does not qualify an installed MV3 extension or native messaging. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +CONTENT = Path(__file__).resolve().parents[1] / "chrome_extension" / "content.js" + +LIVE_SECRET = "hunter2-live-value-must-not-be-sent" + +_PAGE = """ +
+
+""" + + +@pytest.fixture +def page(): + sync = pytest.importorskip("playwright.sync_api") + with sync.sync_playwright() as playwright: + try: + browser = playwright.chromium.launch(headless=True) + except Exception as exc: # noqa: BLE001 - no browser on this machine + pytest.skip(f"chromium unavailable: {exc}") + page = browser.new_page() + page.set_content(_PAGE) + page.add_script_tag(content=CONTENT.read_text(encoding="utf-8")) + page.evaluate("window.__openadaptExtensionContent.setRecording(true)") + page.evaluate("window.__sent.length = 0") + yield page + browser.close() + + +def _evidence(page) -> list[dict]: + return [ + m["evidence"] + for m in page.evaluate("window.__sent") + if m.get("type") == "EXTENSION_EVIDENCE" + ] + + +def test_click_inside_open_shadow_reports_inner_button(page): + page.click("#wrap >> #go") + clicks = [e for e in _evidence(page) if e["event"] == "click"] + assert clicks, "no click evidence" + assert clicks[-1]["tag"] == "button" + assert clicks[-1]["id"] == "go" + assert clicks[-1]["in_shadow"] is True + assert clicks[-1]["path"].endswith(">>> button#go") + + +def test_typing_into_secret_id_with_benign_name_never_sends_value(page): + page.type("#wrap >> #password", LIVE_SECRET) + inputs = [e for e in _evidence(page) if e["event"] == "input"] + assert len(inputs) == len(LIVE_SECRET) + assert all(e["secret_field"] is True for e in inputs) + assert all(e["value"] == "" for e in inputs) + assert all(e["in_shadow"] is True and e["id"] == "password" for e in inputs) + wire = json.dumps(page.evaluate("window.__sent")) + for i in range(3, len(LIVE_SECRET) + 1): + assert LIVE_SECRET[:i] not in wire + + +def test_typing_into_benign_shadow_field_keeps_inner_target(page): + page.type("#wrap >> #memo", "ok") + inputs = [e for e in _evidence(page) if e["event"] == "input"] + assert inputs[-1]["id"] == "memo" + assert inputs[-1]["secret_field"] is False + assert inputs[-1]["value"] == "ok" + + +def test_closed_shadow_input_is_reported_at_host_only(page): + page.evaluate( + "() => { const el = window.__closedInput; el.focus();" + " el.value = '123-45-6789';" + " el.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true})); }" + ) + inputs = [e for e in _evidence(page) if e["event"] == "input"] + assert inputs[-1]["id"] == "locked" + assert inputs[-1]["in_shadow"] is False + assert "123-45-6789" not in json.dumps(page.evaluate("window.__sent")) + + +def test_nothing_is_sent_when_not_recording(page): + page.evaluate("window.__openadaptExtensionContent.setRecording(false)") + page.evaluate("window.__sent.length = 0") + page.click("#wrap >> #go") + page.type("#wrap >> #password", LIVE_SECRET) + assert _evidence(page) == [] diff --git a/packages/flow/tests/test_extension_evidence.py b/packages/flow/tests/test_extension_evidence.py index a78157c352..901456ed4a 100644 --- a/packages/flow/tests/test_extension_evidence.py +++ b/packages/flow/tests/test_extension_evidence.py @@ -149,3 +149,15 @@ def test_native_host_validate_and_write_via_buffer(tmp_path: Path): loaded = load_extension_evidence(tmp_path) assert loaded[0]["value"] == REDACTED_VALUE assert loaded[0]["in_shadow"] is True + + +def test_declared_secret_names_match_id_even_with_benign_name(): + line = { + "kind": "target", + "name": "note", + "id": "member_ssn", + "value": "123-45-6789", + } + out = redact_evidence(line, secret_names=["member_ssn"]) + assert out["secret_field"] is True + assert out["value"] == "" diff --git a/packages/flow/tests/test_extension_shadow_evidence.py b/packages/flow/tests/test_extension_shadow_evidence.py index 853c47beae..0e3da06c69 100644 --- a/packages/flow/tests/test_extension_shadow_evidence.py +++ b/packages/flow/tests/test_extension_shadow_evidence.py @@ -236,3 +236,107 @@ def test_declared_secret_name_detected(): assert results[0]["secret"] is True assert results[1]["secret"] is True assert results[2]["secret"] is False + + +def _shadow_form(*children: dict, mode: str = "open") -> dict: + return { + "host": {"tag": "my-form", "attrs": {"id": "wrap"}}, + "shadow": {"mode": mode, "children": list(children)}, + } + + +def test_benign_name_does_not_hide_secret_id(): + # Review #1977 P1: `name || id` let a benign name mask id="password". + results = _run( + "content", + CONTENT, + [ + {"isSecret": {"tag": "input", "attrs": {"name": "note", "id": "password"}}}, + {"isSecret": {"tag": "input", "attrs": {"name": "password", "id": "note"}}}, + { + "isSecret": { + "tag": "input", + "attrs": {"autocomplete": "current-password"}, + } + }, + {"isSecret": {"tag": "input", "attrs": {"autocomplete": "one-time-code"}}}, + {"isSecret": {"tag": "input", "attrs": {"name": "note", "id": "memo"}}}, + ], + ) + assert [r["secret"] for r in results] == [True, True, True, True, False] + + +def test_dispatched_input_withholds_secret_id_value_before_send(): + live = "hunter2-live-value-must-not-be-sent" + results = _run( + "content", + CONTENT, + [ + {"mode": "record"}, + { + "build": _shadow_form( + { + "tag": "input", + "attrs": {"name": "note", "id": "password"}, + "value": live, + } + ), + "action": "dispatch", + "event": "input", + }, + ], + ) + sent = results[1]["sent"] + assert len(sent) == 1 + evidence = sent[0]["msg"]["evidence"] + assert evidence["secret_field"] is True + assert evidence["value"] == "" + assert live not in json.dumps(sent) + + +def test_dispatched_click_in_open_shadow_reports_inner_target(): + # Review #1977 P2: document listeners see ev.target retargeted to the host. + results = _run( + "content", + CONTENT, + [ + {"mode": "record"}, + { + "build": _shadow_form({"tag": "button", "attrs": {"id": "go"}}), + "action": "dispatch", + "event": "click", + }, + ], + ) + step = results[1] + # The harness really retargeted ev.target, so the old code would lose the leaf. + assert step["dispatched"]["retargeted_id"] == "wrap" + evidence = step["sent"][0]["msg"]["evidence"] + assert evidence["tag"] == "button" + assert evidence["id"] == "go" + assert evidence["in_shadow"] is True + assert evidence["path"].endswith(">>> button#go") + + +def test_dispatched_click_in_closed_shadow_stays_at_host(): + # Closed roots are opaque to page/content JS: evidence stops at the host. + results = _run( + "content", + CONTENT, + [ + {"mode": "record"}, + { + "build": _shadow_form( + {"tag": "input", "attrs": {"name": "ssn"}, "value": "123-45-6789"}, + mode="closed", + ), + "action": "dispatch", + "event": "input", + }, + ], + ) + sent = results[1]["sent"] + evidence = sent[0]["msg"]["evidence"] + assert evidence["tag"] == "my-form" + assert evidence["in_shadow"] is False + assert "123-45-6789" not in json.dumps(sent)