feat(03-03): Task 1 — assertA31 page-side orchestrator (cs-injection-world password-filter probe)

- Add assertA31 page-side orchestrator after assertA30: opens fresh
  https://example.com probe tab via chrome.tabs.create, injects a
  synthetic <input type="password" id="probe-password"> + a control
  <input type="text" id="probe-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 <action> drove document.querySelector('#probe-password')
  on the harness page (chrome-extension://...harness.html). Plan
  03-02 empirically established that <all_urls> 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.
This commit is contained in:
2026-05-20 20:05:22 +02:00
parent de398347e0
commit 8db629f2fb

View File

@@ -3660,6 +3660,262 @@ async function assertA30(): Promise<AssertionResult> {
return result; 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 `<all_urls>` content_scripts assumption
* which is empirically WRONG (Chrome match-pattern spec: `<all_urls>`
* 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 `<input type="password">` 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 `<input type="text">` 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 `<input type="text">`
* 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 `<input>` 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<AssertionResult> {
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 ?? '<pending>'}`);
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 * Read `chrome.runtime.getManifest().version`. Used by the host-side
* orchestrator at startup to capture the expected version for A13's * orchestrator at startup to capture the expected version for A13's
@@ -3715,6 +3971,8 @@ declare global {
assertA29: () => Promise<AssertionResult>; assertA29: () => Promise<AssertionResult>;
// Plan 03-02 — event-log verification (SPEC §10 #5) // Plan 03-02 — event-log verification (SPEC §10 #5)
assertA30: () => Promise<AssertionResult>; assertA30: () => Promise<AssertionResult>;
// Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02)
assertA31: () => Promise<AssertionResult>;
getManifestVersion: () => Promise<string>; getManifestVersion: () => Promise<string>;
}; };
} }
@@ -3751,14 +4009,15 @@ window.__mokoshHarness = {
assertA28, assertA28,
assertA29, assertA29,
assertA30, assertA30,
assertA31,
getManifestVersion, getManifestVersion,
}; };
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
if (statusEl !== null) { 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 {}; export {};