From 8db629f2fb3d416fab2cba19978755eb89d7830d Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 20 May 2026 20:05:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(03-03):=20Task=201=20=E2=80=94=20assertA31?= =?UTF-8?q?=20page-side=20orchestrator=20(cs-injection-world=20password-fi?= =?UTF-8?q?lter=20probe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add assertA31 page-side orchestrator after assertA30: opens fresh https://example.com probe tab via chrome.tabs.create, injects a synthetic + a control into the probe tab DOM via chrome.scripting.executeScript world:'ISOLATED', types A31_PASSWORD_SENTINEL='secret-do-not-log-123' + A31_CONTROL_SENTINEL into each, dispatches input events, settles, SAVEs while the probe tab is active, finally-cleanup with silent-ignore (T-02-04-04 parity). - Add 8 module-local constants: A31_SAVE_ARCHIVE_TIMEOUT_MS=15s, A31_SEGMENT_SETTLE_MS=11s, A31_TRIGGER_SETTLE_MS=1s, A31_TAB_NAVIGATION_WAIT_MS=1.5s, A31_PROBE_TAB_URL, A31_PASSWORD_SENTINEL, A31_CONTROL_SENTINEL, A31_PASSWORD_SELECTOR='#probe-password', A31_PASSWORD_INPUT_ID, A31_CONTROL_INPUT_ID. - Extend declare global Window.__mokoshHarness interface with assertA31 + add assertA31 to window.__mokoshHarness object literal + update statusEl banner + closing console.log to A31. - 1 page-side check: A31.1 (SAVE_ARCHIVE ack). Host-side driveA31 (Task 2) will append A31.2 (sentinel-value-absent) + A31.3 (zero-events-targeting-password-selector) + A31.4 (control event present — defense-in-depth proof the listener is alive, so A31.2 and A31.3 GREEN actually mean the filter fired rather than a tautological pass from no events at all). Rule 3 — Auto-fix blocking (cs-injection-world adaptation): - The plan's drove document.querySelector('#probe-password') on the harness page (chrome-extension://...harness.html). Plan 03-02 empirically established that content_scripts does NOT cover chrome-extension scheme (Chrome match-pattern spec permits http/https/file/ftp/urn only). With no content script on the harness page, A31.2/A31.3 would pass tautologically (no events captured regardless of input type — would not empirically verify the line-82 filter "fires"). - A31 reuses the Plan 03-02 cs-injection-world pattern: probe tab on https://example.com (where the content script attaches normally) + executeScript ISOLATED-world injection so production setupInputLogging at src/content/index.ts:78 actually sees the password input event AND its line-82 filter early-returns. - A31.4 control-event check is added as defense-in-depth per T-03-03-04: proves the listener IS alive, so the absence assertions A31.2/A31.3 are not vacuously satisfied. - Plan's binding contract (sentinel absent from logs/events.json + zero events targeting password selector) preserved verbatim; only the trigger mechanism changes. FORBIDDEN_HOOK_STRINGS impact: NONE. A31 rides production setupInputLogging + line-82 filter + chrome.tabs + chrome.scripting (scripting perm already in manifest) + existing setupFreshRecording/sendMessageWithTimeout helpers. Tier-1 unchanged at 12. --- tests/uat/extension-page-harness.ts | 263 +++++++++++++++++++++++++++- 1 file changed, 261 insertions(+), 2 deletions(-) diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 0ea9db0..3fde9d1 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -3660,6 +3660,262 @@ async function assertA30(): Promise { return result; } +/* ─── Plan 03-03 — A31 (password-filter PARTIAL; SPEC §10 #8) ──────── + * + * A31 — D-P3-02 PARTIAL: verify the existing minimum filter at + * src/content/index.ts:82 (`if (target.type === 'password') return;`) + * fires when the operator types into a password input. + * Negative-assertion contract: SENTINEL value MUST be absent + * from logs/events.json. + * + * Charter alignment (per CONTEXT.md D-P3-02 + REQUIREMENTS.md line 271): + * - REQ-password-confidentiality moved Out of Scope v1 per 2026-05-20 + * charter "we don't care about privacy hardening. At least here." + * - Full rrweb v2 maskInputFn + data-sensitive HTML attribute guards + * DEFERRED to Phase 4 if charter reverses. + * - A31 verifies the EXISTING minimum (the line-82 filter) — does + * NOT expand scope. + * + * Implementation note — cs-injection-world adaptation (Rule 3 blocking + * auto-fix; mirrors Plan 03-02 architectural fix): + * The plan as written drove `document.querySelector('#probe-password')` + * on the harness page (chrome-extension://...harness.html). That + * matches Plan 03-02's `` content_scripts assumption + * which is empirically WRONG (Chrome match-pattern spec: `` + * covers http/https/file/ftp/urn only — NOT chrome-extension). With + * no content script attached to the harness page, the production + * setupInputLogging at src/content/index.ts:78 never sees the + * harness-page input event AT ALL, so A31.2 (absence-of-sentinel) + * and A31.3 (absence-of-#probe-password-target) would pass + * tautologically — neither check empirically verifies the line-82 + * filter "fires", only that NO event is captured on the harness page + * regardless of input type. That is NOT a valid §10 #8 PARTIAL + * verification (the filter could be deleted and the test would + * still pass). + * + * A31 therefore reuses the Plan 03-02 cs-injection-world pattern: + * open a fresh https://example.com probe tab where the content + * script DOES attach, inject a `` element + + * type the SENTINEL value + dispatch input event in the + * content-script's ISOLATED world, SAVE while the probe tab is + * active, finally-cleanup the probe tab. For a control case + * (verifies the wiring is operational), the same injection also + * types a control sentinel into a `` element + * — the production setupInputLogging MUST capture that event, + * PROVING the path that would have fired for the password input + * IS active. The control case is host-side-only check + * A31.4 (control event present); the production filter at + * src/content/index.ts:82 early-returns BEFORE addUserEvent so + * the password event NEVER lands in userEvents[] (A31.2 + A31.3). + * + * This satisfies the plan's binding contract literally: + * - artifact "types sentinel into the probe-page password input + * via setupFreshRecording + SAVE" — done (just in a different + * page that has the content script alive) + * - truths #1/#2/#3 (sentinel-value-absent + zero-events-targeting- + * password) — empirically verified because the input event IS + * seen by the production listener + filter + * - threat T-03-03-04 (defense-in-depth) — A31.4 control case is + * the third orthogonal path proving the listeners are alive + * + * FORBIDDEN_HOOK_STRINGS impact: NONE. A31 rides production + * setupInputLogging at src/content/index.ts:78 + the line-82 filter + * + chrome.tabs.* + chrome.scripting.executeScript + existing helpers. + * Tier-1 inventory stays at 12 entries. + */ + +/** SAVE_ARCHIVE dispatch timeout for A31 — matches A24/A25/A27/A29/A30. */ +const A31_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; +/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */ +const A31_SEGMENT_SETTLE_MS = 11_000; +/** Settle after sentinel-typing trigger so the synchronous handler completes. */ +const A31_TRIGGER_SETTLE_MS = 1_000; +/** Wait after chrome.tabs.create for the tab navigation to complete so + * the content script attaches + production listeners are set up + * (mirrors A30_TAB_NAVIGATION_WAIT_MS = 1.5s). */ +const A31_TAB_NAVIGATION_WAIT_MS = 1_500; +/** Probe tab URL — example.com is RFC 2606 reserved + serves stable + * HTML under headless Chrome (Plan 02-04 A27 + Plan 03-02 A30 fixture parity). */ +const A31_PROBE_TAB_URL = 'https://example.com/'; +/** Fixed test sentinel — distinctive string the negative-assertion + * searches for in events.json. Per RESEARCH §"Security Domain": + * this is a probe sentinel, NOT a real secret; logging it would + * itself trigger an explicit RED. */ +const A31_PASSWORD_SENTINEL = 'secret-do-not-log-123'; +/** Control sentinel — distinctive string typed into a `` + * element in the same injection. Production setupInputLogging at + * src/content/index.ts:78 MUST capture this — driveA31's A31.4 + * check verifies its presence as proof that the listener is alive + * (defense-in-depth against T-03-03-04 — if the production listener + * weren't running at all, A31.2/A31.3 would pass tautologically; + * A31.4 GREEN proves the listener IS running, so A31.2/A31.3 GREEN + * actually mean the filter fired). */ +const A31_CONTROL_SENTINEL = 'control-event-must-be-logged-a31'; +/** Production CSS selector returned by getSelector() at + * src/content/index.ts:241 for the password input (which has id). + * Drives A31.3 (target-absence check). */ +const A31_PASSWORD_SELECTOR = '#probe-password'; +/** Synthetic password-input element id (matches A31_PASSWORD_SELECTOR + * after the leading `#` is stripped). Injected into the probe tab DOM + * by chrome.scripting.executeScript. */ +const A31_PASSWORD_INPUT_ID = 'probe-password'; +/** Synthetic control-input element id — referenced by driveA31's + * A31.4 check via the same `#probe-control` selector. */ +const A31_CONTROL_INPUT_ID = 'probe-control'; + +/** + * A31 — Password-filter PARTIAL empirical (SPEC §10 #8 PARTIAL per D-P3-02). + * + * Creates a fresh `https://example.com` probe tab (where the content + * script attaches normally per Plan 03-02 cs-injection-world insight), + * injects two `` elements (a control `type="text"` + a sentinel + * `type="password"`) + types the corresponding sentinels + dispatches + * input events in the content-script's ISOLATED world, settles a + * segment, SAVEs while the probe tab is active, finally-cleanup the + * tab. Host-side driveA31 inspects logs/events.json and asserts: + * - The password SENTINEL is ABSENT from any UserEvent.value field + * (proves the line-82 filter early-returned before addUserEvent) + * - Zero UserEvent entries have target === '#probe-password' + * (proves the same filter via the orthogonal selector path) + * - At least one UserEvent contains the control sentinel + * (proves the listener was alive — defense-in-depth against + * the trivial "no events at all" tautology) + * + * @returns AssertionResult with 1 page-side check (SAVE ack); host-side + * driveA31 appends host-side checks for sentinel absence + * (A31.2 + A31.3) + control presence (A31.4). + */ +async function assertA31(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A31 — password filter fires (SPEC §10 #8 PARTIAL per D-P3-02)', + checks: [], + diagnostics: [], + }; + + let probeTabId: number | undefined; + + try { + diag(result, 'Step 1: setupFreshRecording (A31 owns its recording — clean event-log window)'); + const setupResp = await setupFreshRecording(); + if (!setupResp.ok) { + throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`); + } + diag(result, 'Step 1 OK — REC state established'); + + diag(result, `Step 2: chrome.tabs.create(${A31_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension:// (Plan 03-02 lesson)`); + const probeTab = await chrome.tabs.create({ url: A31_PROBE_TAB_URL, active: true }); + probeTabId = probeTab.id; + diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? ''}`); + if (probeTabId === undefined) { + throw new Error('chrome.tabs.create returned undefined tab.id'); + } + + diag(result, `Step 3: wait ${A31_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach`); + await new Promise((r) => setTimeout(r, A31_TAB_NAVIGATION_WAIT_MS)); + + diag(result, `Step 4: settle ${A31_SEGMENT_SETTLE_MS}ms for first segment rotation`); + await new Promise((r) => setTimeout(r, A31_SEGMENT_SETTLE_MS)); + + diag(result, 'Step 5: chrome.scripting.executeScript — inject password+control inputs + dispatch input events in ISOLATED world (production setupInputLogging at src/content/index.ts:78 sees BOTH, line-82 filter early-returns on the password input only)'); + const injectionResults = await chrome.scripting.executeScript({ + target: { tabId: probeTabId }, + world: 'ISOLATED', + func: ( + passwordInputId: string, + passwordSentinel: string, + controlInputId: string, + controlSentinel: string, + ): { + passwordTyped: boolean; + controlTyped: boolean; + passwordDispatched: boolean; + controlDispatched: boolean; + } => { + // Create the synthetic password input. Production + // setupInputLogging at src/content/index.ts:78 attaches via + // document.addEventListener('input', ...), so the production + // path covers any element added to document.body — including + // ones created and dispatched synchronously by us. + const passwordInput = document.createElement('input'); + passwordInput.type = 'password'; + passwordInput.id = passwordInputId; + passwordInput.value = passwordSentinel; + document.body.appendChild(passwordInput); + const passwordDispatched = passwordInput.dispatchEvent( + new Event('input', { bubbles: true }), + ); + + // Create the synthetic control input. setupInputLogging will + // see this one too — but because target.type !== 'password', + // the line-82 filter does NOT early-return; addUserEvent fires + // and the event lands in userEvents[] with type='input' and + // value containing the control sentinel. A31.4 host-side + // verifies this (defense-in-depth proving the listener IS + // alive — without it, A31.2/A31.3 would pass tautologically). + const controlInput = document.createElement('input'); + controlInput.type = 'text'; + controlInput.id = controlInputId; + controlInput.value = controlSentinel; + document.body.appendChild(controlInput); + const controlDispatched = controlInput.dispatchEvent( + new Event('input', { bubbles: true }), + ); + + return { + passwordTyped: passwordInput.value === passwordSentinel, + controlTyped: controlInput.value === controlSentinel, + passwordDispatched, + controlDispatched, + }; + }, + args: [ + A31_PASSWORD_INPUT_ID, + A31_PASSWORD_SENTINEL, + A31_CONTROL_INPUT_ID, + A31_CONTROL_SENTINEL, + ], + }); + const injectionSummary = injectionResults[0]?.result ?? null; + diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`); + + diag(result, `Step 6: settle ${A31_TRIGGER_SETTLE_MS}ms so synchronous handlers complete + userEvents[] populates`); + await new Promise((r) => setTimeout(r, A31_TRIGGER_SETTLE_MS)); + + diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)'); + const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>( + { type: 'SAVE_ARCHIVE' }, + A31_SAVE_ARCHIVE_TIMEOUT_MS, + 'SAVE_ARCHIVE (A31)', + ); + diag(result, `Step 7 result: ${JSON.stringify(ack)}`); + + result.checks.push({ + name: 'A31.1: SAVE_ARCHIVE ack received with success=true', + expected: true, + actual: ack.success, + passed: ack.success === true, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } finally { + // T-02-04-04 mitigation parity (Plan 03-02 precedent): cleanup probe + // tab with silent-ignore on already-closed. + if (probeTabId !== undefined) { + try { + await chrome.tabs.remove(probeTabId); + } catch (rmErr) { + diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`); + } + } + } + + return result; +} + /** * Read `chrome.runtime.getManifest().version`. Used by the host-side * orchestrator at startup to capture the expected version for A13's @@ -3715,6 +3971,8 @@ declare global { assertA29: () => Promise; // Plan 03-02 — event-log verification (SPEC §10 #5) assertA30: () => Promise; + // Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02) + assertA31: () => Promise; getManifestVersion: () => Promise; }; } @@ -3751,14 +4009,15 @@ window.__mokoshHarness = { assertA28, assertA29, assertA30, + assertA31, getManifestVersion, }; const statusEl = document.getElementById('status'); if (statusEl !== null) { - statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A30, getManifestVersion} available.'; + statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A31, getManifestVersion} available.'; } -console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + Plan 03-02: A30 + getManifestVersion)'); +console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + Plan 03-02: A30 + Plan 03-03: A31 + getManifestVersion)'); export {};