chore: merge executor worktree (worktree-agent-ab90594c47b888094) — Wave 3 Plan 03-03
This commit is contained in:
@@ -0,0 +1,293 @@
|
|||||||
|
---
|
||||||
|
phase: 03-spec-10-smoke-verification-dom-event-log-verification
|
||||||
|
plan: 03
|
||||||
|
subsystem: testing
|
||||||
|
tags:
|
||||||
|
- uat-harness
|
||||||
|
- a31
|
||||||
|
- password-filter
|
||||||
|
- spec-10-8-partial
|
||||||
|
- req-password-confidentiality-out-of-scope-v1
|
||||||
|
- approach-b
|
||||||
|
- cs-injection-world
|
||||||
|
- negative-assertion
|
||||||
|
- charter-d-p3-02
|
||||||
|
- phase-3-wave-3
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- phase: 01-stabilize-video-pipeline
|
||||||
|
provides: "Plan 01-13 UAT harness Approach B (extension-internal page + synthetic MediaStream; page-side assertA* + host-side driveA* + harness.test.ts orchestrator); FORBIDDEN_HOOK_STRINGS lockstep pattern"
|
||||||
|
- phase: 02-stabilize-export-pipeline
|
||||||
|
provides: "Plan 02-04 A27 multi-tab pattern (chrome.tabs.create + activate + cleanup with silent-ignore finally; T-02-04-04 mitigation parity); A24-A28 chained-zip pattern; findLatestZip helper; JSZip host-side parse pattern; DEC-011 Amendment 1 tabs permission grant + scripting permission; src/content/index.ts:82 password filter (production-shipped Phase 1; verification subject — UNMODIFIED)"
|
||||||
|
- plan: 03-02
|
||||||
|
provides: "Plan 03-02 cs-injection-world pattern (chrome.tabs.create probe tab + chrome.scripting.executeScript ISOLATED-world injection); UserEvent type import in harness-page-driver.ts; T-02-04-04 silent-ignore finally cleanup; assertA30 page-side orchestrator scaffold (most-recent analog); empirical PROOF that <all_urls> content_scripts does NOT cover chrome-extension:// (Chrome match-pattern spec)"
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- 1 new UAT harness assertion (A31) empirically verifying SPEC §10 #8 PARTIAL per D-P3-02: the existing src/content/index.ts:82 `if (target.type === 'password') return;` filter fires when the operator types into a password input on a https://example.com probe tab where the content script is alive
|
||||||
|
- assertA31 page-side orchestrator (chrome.tabs.create probe tab + chrome.scripting.executeScript ISOLATED-world injection of synthetic <input type="password"> + control <input type="text"> + sentinel typing + finally tab cleanup) at tests/uat/extension-page-harness.ts
|
||||||
|
- driveA31 host-side 3-phase driver (page.evaluate + findLatestZip + JSZip logs/events.json + filter-pipeline negative-assertion grep for sentinel absence + filter-pipeline positive-assertion grep for control sentinel presence) at tests/uat/lib/harness-page-driver.ts
|
||||||
|
- A31_PASSWORD_SENTINEL = 'secret-do-not-log-123' (fixed test constant; RFC-style sentinel; not a real secret)
|
||||||
|
- A31_CONTROL_SENTINEL = 'control-event-must-be-logged-a31' (defense-in-depth: proves the production listener is alive)
|
||||||
|
- Orchestrator extension: drivers array 30 → 31; total 32/32 with A0; banner mentions A31
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- phase-03 plan 05 (Plan 03-05 VERIFICATION.md aggregator inherits A31 as the binding §10 #8 PARTIAL empirical gate + cites D-P3-02 charter rationale for the partial mark)
|
||||||
|
- phase-04 future REQ-password-confidentiality v2 candidate (rrweb v2 maskInputFn + data-sensitive HTML attribute guards) if the charter reverses ("we don't care about privacy hardening" → "we do") — A31's contract becomes the floor (existing minimum); full masking becomes the new ceiling
|
||||||
|
- phase-04 candidate: pre-existing A29 zip-mtime race-condition flake (Plan 03-02 SUMMARY "Issues Encountered" deferred item; surfaced again during Plan 03-03 empirical UAT — see "Issues Encountered" below)
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Negative-assertion contract via filter-pipeline (CLAUDE.md Control Flow §): userEvents.filter((e) => typeof e.value === 'string' && e.value.includes(SENTINEL)) — both PRESENCE (control) and ABSENCE (password) checks share the same filter-pipeline form; no `continue`; if-else chains over early returns."
|
||||||
|
- "Defense-in-depth orthogonal-channel verification: A31.4 control-sentinel-PRESENT check proves the production listener is alive so the absence checks A31.2 + A31.3 are NOT vacuously satisfied. Plan 02-04 A27.7/A27.8 is the closest established precedent for orthogonal-channel absence-checks; A31.4 extends the idiom with an active presence check."
|
||||||
|
- "cs-injection-world reuse (Plan 03-02 precedent): chrome.tabs.create(https://...) + chrome.scripting.executeScript({world:'ISOLATED', func:...}) for any verification that requires production content-script listeners to fire and be captured in the assembled archive. Plan 03-03 reuses verbatim for the password-input scenario; the only addition is the control input in the same injection."
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
modified:
|
||||||
|
- tests/uat/extension-page-harness.ts (assertA31 page-side orchestrator with chrome.tabs.create probe tab + chrome.scripting.executeScript ISOLATED-world injection of password + control inputs; 10 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='https://example.com/', A31_PASSWORD_SENTINEL='secret-do-not-log-123', A31_CONTROL_SENTINEL='control-event-must-be-logged-a31', A31_PASSWORD_SELECTOR='#probe-password', A31_PASSWORD_INPUT_ID='probe-password', A31_CONTROL_INPUT_ID='probe-control'; __mokoshHarness surface 30 → 31 methods; statusEl + console banner updated A30 → A31)
|
||||||
|
- tests/uat/lib/harness-page-driver.ts (driveA31 host-side; 3 host-side constants A31_PASSWORD_SENTINEL + A31_CONTROL_SENTINEL + A31_PASSWORD_SELECTOR; 5 visible checks A31.1+A31.0a/0b+A31.2+A31.3+A31.4 + guard checks; filter-pipeline form preserved)
|
||||||
|
- tests/uat/harness.test.ts (driveA31 import + driveA31Wrapped const + drivers array push + Architecture banner A30 → A30, A31)
|
||||||
|
created: []
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Architectural adaptation (Rule 3 — blocking auto-fix) → cs-injection-world pattern. The plan as written 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 — verified 2026-05-20T17:36:25Z SW dump). With no content script on the harness page, the production setupInputLogging at src/content/index.ts:78 never sees the harness-page input event AT ALL, so A31.2 (sentinel absence) and A31.3 (zero-events-targeting-password) would pass tautologically — neither check would empirically verify the line-82 filter 'fires', only that NO event is captured on the harness page regardless of input type. That would NOT be a valid §10 #8 PARTIAL verification (the filter could be deleted and the test would still pass). A31 therefore reuses Plan 03-02's cs-injection-world pattern: opens a fresh https://example.com probe tab where the content script attaches normally, injects a synthetic <input type='password' id='probe-password'> + a control <input type='text' id='probe-control'> via chrome.scripting.executeScript ISOLATED-world, types both sentinels, SAVEs while the probe tab is active, finally-cleanup."
|
||||||
|
- "Defense-in-depth orthogonal-channel check A31.4. Without a positive-control assertion, A31.2 + A31.3 could pass even if the production listener weren't running at all — a 'no events captured' tautology. A31.4 asserts that the control sentinel (typed into a text input — production setupInputLogging fires + line-82 filter does NOT early-return + addUserEvent captures the event) IS present in logs/events.json. A31.4 GREEN proves the listener is alive, so the absences in A31.2/A31.3 actually mean the line-82 filter fired rather than the listener being silent. Strict T-03-03-04 (Repudiation) mitigation per threat-model."
|
||||||
|
- "Honor charter literal per D-P3-02. Plan 03-03 verifies the EXISTING minimum filter at src/content/index.ts:82. NO new masking surfaces added (no rrweb maskInputFn, no data-sensitive HTML attribute guards). REQ-password-confidentiality remains Out of Scope v1; Plan 03-05 VERIFICATION.md marks §10 #8 as PARTIAL with explicit charter citation 'we don't care about privacy hardening. At least here.' 2026-05-20."
|
||||||
|
- "src/content/index.ts unmodified. The plan's <objective> + key-links + success-criteria all designate src/content/index.ts:82 as a READ-ONLY VERIFICATION SUBJECT. `git diff src/content/index.ts` against base returns empty; `git log` shows zero commits touching src/content/index.ts since base."
|
||||||
|
- "Filter-pipeline form preserved (no `continue`). Both eventsContainingSentinel = userEvents.filter(...) and eventsTargetingPassword = userEvents.filter(...) and eventsContainingControl = userEvents.filter(...) follow the established Plan 02-04 driveA28 + Plan 03-02 driveA30 pattern. CLAUDE.md Control Flow § honored."
|
||||||
|
- "Tier-1 FORBIDDEN_HOOK_STRINGS inventory unchanged at 12 entries. A31 rides production setupInputLogging at src/content/index.ts:78 + line-82 filter + chrome.tabs.create/remove (DEC-011 Amendment 1 perm) + chrome.scripting.executeScript (scripting perm already in manifest) + existing setupFreshRecording/sendMessageWithTimeout helpers. No new `__MOKOSH_UAT__`-gated test-only symbols. 13/13 unit-gate sub-tests GREEN; 12 strings × 0 hits in dist/."
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Defense-in-depth absence + presence combo: pair every absence check (negative-assertion) with at least one orthogonal-channel presence check (positive-assertion) to defeat the 'no events at all' tautology. Reusable for any future SPEC §10 PARTIAL or charter-shift PARTIAL verifications."
|
||||||
|
|
||||||
|
requirements-completed: []
|
||||||
|
# REQ-password-confidentiality remains Out of Scope v1 per D-P3-02. A31
|
||||||
|
# verifies the EXISTING minimum; the full REQ stays open in REQUIREMENTS.md
|
||||||
|
# Out of Scope section pending charter reversal.
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: "~55 min (Phase 3 Wave 3; third plan; longest of three because of the A29-flake disclosure path)"
|
||||||
|
completed: 2026-05-20
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 03 Plan 03: A31 password-filter PARTIAL harness extension Summary
|
||||||
|
|
||||||
|
**Single new harness assertion (A31) empirically verifies SPEC §10 #8 PARTIAL per D-P3-02 charter end-to-end through a real Chrome instance: the existing `src/content/index.ts:82` `if (target.type === 'password') return;` filter fires when a sentinel string is typed into a synthetic `<input type="password">` element injected into a https://example.com probe tab where the content script is alive. The sentinel value is ABSENT from `logs/events.json`; zero events target the password selector; the control sentinel (typed into a text input in the same injection) IS present (defense-in-depth proving the listener is alive). UAT count 31 → 32 GREEN; vitest 171/171 preserved; Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12; src/content/index.ts UNMODIFIED.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~55 min (Phase 3 Wave 3; third plan; including the A29-flake disclosure path that prompted a reset + isolated-Task-1 re-run to confirm pre-existing condition)
|
||||||
|
- **Started:** 2026-05-20T17:48:52Z (worktree spawn, after Plan 03-02 closure)
|
||||||
|
- **Completed:** 2026-05-20T18:44:10Z (SUMMARY drafted; tracked back to last UAT run completion)
|
||||||
|
- **Tasks:** 2 of 2 plan tasks complete (both autonomous; no checkpoints)
|
||||||
|
- **Files modified:** 3 (TypeScript harness wires only; no probe HTML change; no production code change)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- **A31 (SPEC §10 #8 PARTIAL per D-P3-02):** 5 visible checks — A31.1 SAVE_ARCHIVE ack (page-side) + A31.0a logs/events.json present + A31.2 sentinel-value-ABSENT + A31.3 zero-events-targeting-#probe-password + A31.4 control-sentinel-PRESENT (defense-in-depth). EMPIRICALLY verified GREEN with `userEvents.length=1`, `sentinel-containing count=0`, `password-targeting count=0`, `control-containing count=1` — exactly the contract.
|
||||||
|
- **cs-injection-world pattern reused verbatim (Plan 03-02 precedent):** A31 opens a https://example.com probe tab + chrome.scripting.executeScript ISOLATED-world injection of two synthetic `<input>` elements (password + control) + types both sentinels + SAVEs while the probe tab is active + finally-cleanup with silent-ignore. The control-input in the same injection is the orthogonal-channel positive assertion proving the production setupInputLogging listener IS alive — so the absences A31.2/A31.3 actually mean the line-82 filter fired (NOT the trivial "no events at all" tautology).
|
||||||
|
- **Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12:** A31 rides production setupInputLogging + line-82 filter + chrome.tabs.* + chrome.scripting.executeScript + existing helpers. 13/13 unit-gate sub-tests GREEN; 12 strings × 0 hits each in dist/.
|
||||||
|
- **vitest baseline preserved:** 171/171 GREEN (31 test files; 13.62s + 46.68s parallel test runs).
|
||||||
|
- **src/content/index.ts UNMODIFIED:** verification-only contract honored. `git diff src/content/index.ts` against base returns empty; `git log` shows zero commits touching src/content/index.ts since base. The line-82 filter is the verification subject; D-P3-02 charter scope respected.
|
||||||
|
- **tsc clean:** `npx tsc --noEmit` exit 0.
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each plan task was committed atomically (`--no-verify` per parallel-executor protocol):
|
||||||
|
|
||||||
|
1. **Task 1: assertA31 page-side orchestrator (cs-injection-world password-filter probe)** — `8db629f` (feat). Includes the Rule-3 architectural fix inline because the plan-spec strategy was empirically untestable (chrome-extension:// has no content script per Plan 03-02 finding).
|
||||||
|
2. **Task 2: driveA31 + orchestrator wiring (A31 password-filter PARTIAL)** — `34b36fb` (feat). Host-side 3-phase pattern + orchestrator banner update + drivers-array push.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `tests/uat/extension-page-harness.ts` — assertA31 page-side orchestrator + 10 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='https://example.com/', A31_PASSWORD_SENTINEL='secret-do-not-log-123', A31_CONTROL_SENTINEL='control-event-must-be-logged-a31', A31_PASSWORD_SELECTOR='#probe-password', A31_PASSWORD_INPUT_ID='probe-password', A31_CONTROL_INPUT_ID='probe-control'); declare global Window.__mokoshHarness interface extended; window.__mokoshHarness object literal extended; statusEl + console banner updated A30 → A31; Plan 03-03 added to closing console.log.
|
||||||
|
- `tests/uat/lib/harness-page-driver.ts` — driveA31 host-side (3-phase: page.evaluate stub-with-side-effects → findLatestZip → JSZip logs/events.json + UserEvent filter-pipeline grep). 5 visible checks (A31.1 page-side + A31.0a entry-present + A31.2 sentinel-absent + A31.3 password-target-absent + A31.4 control-present) + guard checks. 3 host-side constants mirroring page-side sentinels + selector.
|
||||||
|
- `tests/uat/harness.test.ts` — driveA31 import + driveA31Wrapped const (mirrors driveA26/A27/A28/A29/A30 downloadsDir-needs wrapping) + drivers array push entry with Plan 03-03 banner + Architecture banner string updated A30 → A30, A31.
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- **Architectural adaptation during Task 1 (Rule 3 — auto-fix blocking).** The plan as written drove `document.querySelector('#probe-password')` on the harness page (chrome-extension://...harness.html). Plan 03-02 SUMMARY (Issues Encountered + Decisions Made) empirically established two facts:
|
||||||
|
1. The Chrome `<all_urls>` content_scripts match pattern does NOT cover the `chrome-extension://` scheme — it permits ONLY `http`, `https`, `file`, `ftp`, `urn`.
|
||||||
|
2. The content script's `window.fetch = ...` override at src/content/index.ts:167 is bound to its ISOLATED world only; even if (1) had held, page-world fetch from page.evaluate would NEVER reach the wrapper.
|
||||||
|
|
||||||
|
With neither (1) nor (2) holding, the plan-spec test would have been a tautology: the production setupInputLogging at src/content/index.ts:78 never sees the harness-page input event because no content script is attached. A31.2 (sentinel absence) + A31.3 (zero events targeting password selector) would pass even if the line-82 filter were deleted — because the listener wasn't running on the harness page at all. NOT a valid §10 #8 PARTIAL verification.
|
||||||
|
|
||||||
|
The fix: reuse Plan 03-02's cs-injection-world pattern verbatim. Open a fresh https://example.com probe tab via chrome.tabs.create (where the content script attaches normally), use chrome.scripting.executeScript with default `world: 'ISOLATED'` to inject the synthetic `<input type="password">` + control `<input type="text">` directly in the content-script realm, dispatch input events in the same world, SAVE while the probe tab is active so the SW harvests events from the right tab, finally-cleanup with silent-ignore (T-02-04-04 parity).
|
||||||
|
|
||||||
|
Plan binding contract preserved literally:
|
||||||
|
- `truths[0]` "Typing a sentinel value into the probe-page password input does NOT cause that value to appear in logs/events.json" — A31.2 PASS (count=0)
|
||||||
|
- `truths[1]` "Counts of UserEvent entries whose .value field contains the sentinel string = 0" — A31.2 PASS
|
||||||
|
- `truths[2]` "Counts of UserEvent entries whose .target selector points at the password input = 0" — A31.3 PASS (count=0)
|
||||||
|
- `truths[3]` "UAT harness exits 0 with 31 + 1 = 32/32 assertions GREEN" — PASS
|
||||||
|
|
||||||
|
- **Defense-in-depth orthogonal-channel verification A31.4.** Without a positive-control assertion, A31.2 + A31.3 could trivially pass even if the production listener weren't running at all — a "no events captured" tautology. A31.4 asserts the control sentinel (typed into a text input — production setupInputLogging fires + line-82 filter does NOT early-return because target.type==='text' + addUserEvent captures the event with .value containing the control sentinel) IS present in logs/events.json (>=1 occurrence). A31.4 GREEN proves the listener is alive, so A31.2/A31.3 absences actually mean the line-82 filter fired. Direct mitigation for threat T-03-03-04 (Repudiation: "If A31 GREEN but the production filter actually broke, the assertion would mislead").
|
||||||
|
|
||||||
|
- **src/content/index.ts unmodified.** Plan's `<objective>` + key-links + success-criteria + threat-model all designate src/content/index.ts:82 as a READ-ONLY VERIFICATION SUBJECT. `git diff src/content/index.ts` against base de398347 returns empty; `git log` shows zero commits touching src/content/index.ts since base. Plan 03-03 honors the verification-only charter literally.
|
||||||
|
|
||||||
|
- **No new masking surfaces added (D-P3-02 charter literal honored).** Plan 03-03 deliberately did NOT add rrweb v2 maskInputFn, data-sensitive HTML attribute guards, or any other defense-in-depth beyond the existing line-82 filter. REQ-password-confidentiality remains Out of Scope v1; Plan 03-05 VERIFICATION.md will mark §10 #8 as PARTIAL with explicit charter citation. Full masking deferred to Phase 4 if charter reverses.
|
||||||
|
|
||||||
|
- **Filter-pipeline form preserved (no `continue`).** Three independent filter-pipeline grep paths: `eventsContainingSentinel = userEvents.filter((e) => typeof e.value === 'string' && e.value.includes(A31_PASSWORD_SENTINEL))`, `eventsTargetingPassword = userEvents.filter((e) => e.target === A31_PASSWORD_SELECTOR)`, `eventsContainingControl = userEvents.filter((e) => typeof e.value === 'string' && e.value.includes(A31_CONTROL_SENTINEL))`. Pattern aligns with Plan 02-04 driveA28's filter-pipeline shape + Plan 03-02 driveA30's Map-based 5-tuple presence grep. CLAUDE.md Control Flow § honored.
|
||||||
|
|
||||||
|
- **Plan 03-02 patterns reused verbatim.** A30's chrome.tabs.create + chrome.scripting.executeScript ISOLATED + SAVE-while-probe-tab-active + finally-cleanup pattern + driveA30's 3-phase JSZip read pattern. The only genuinely-new contribution is the A31.4 defense-in-depth orthogonal-channel pattern; everything else reuses 03-02's established surface.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 — Blocking Architectural Misassumption] Plan's page-side `document.querySelector('#probe-password')` would have been tautological on chrome-extension:// harness page**
|
||||||
|
|
||||||
|
- **Found during:** Task 1 implementation, when cross-referencing with Plan 03-02 SUMMARY's "Issues Encountered" section + 03-CONTEXT.md `<context_addendum>` directive: "cs-injection-world pattern from Plan 03-02 is the canonical way to drive content-script context (chrome.tabs.create + chrome.scripting.executeScript). Plain page.evaluate from puppeteer won't reach the content script (chrome-extension:// match-pattern exclusion per Chrome match-pattern spec)."
|
||||||
|
- **Root cause:** Same as Plan 03-02 Deviation #1 — `<all_urls>` content_scripts matcher does NOT include the chrome-extension scheme. The harness page (chrome-extension://...harness.html) has NO content script attached. Page-side `document.querySelector('#probe-password').dispatchEvent(new Event('input'))` would have fired against a DOM element on a page where the production setupInputLogging at src/content/index.ts:78 is not running.
|
||||||
|
- **Why it matters:** A31.2 + A31.3 would have passed tautologically — no event captured regardless of input type, because the listener isn't running. The line-82 filter contract ("fires on password inputs") would NOT have been empirically verified; the filter could be deleted and the test would still pass.
|
||||||
|
- **Fix:** Adopted Plan 03-02's cs-injection-world pattern verbatim. Opens a fresh https://example.com probe tab via chrome.tabs.create where the content script attaches normally + injects two synthetic `<input>` elements (password + control) via chrome.scripting.executeScript with default `world: 'ISOLATED'` + types both sentinels + SAVEs while the probe tab is active. The production setupInputLogging now actually sees both events; the line-82 filter early-returns for the password input (A31.2 + A31.3 ABSENT) but NOT for the control input (A31.4 PRESENT — defense-in-depth proof).
|
||||||
|
- **Files modified:** tests/uat/extension-page-harness.ts (assertA31 body using cs-injection-world from inception; 10 module constants including A31_PROBE_TAB_URL, A31_CONTROL_SENTINEL, A31_TAB_NAVIGATION_WAIT_MS).
|
||||||
|
- **Result:** A31 GREEN with 5/5 checks. Empirical contract literally satisfied: `userEvents.length=1`, `sentinel-containing count=0`, `password-targeting count=0`, `control-containing count=1`.
|
||||||
|
- **Committed in:** `8db629f` (Task 1 commit; the architectural fix is inline because the plan-spec strategy was untestable from inception).
|
||||||
|
|
||||||
|
**2. [Rule 2 — Critical Functionality Addition] A31.4 control-sentinel-PRESENT defense-in-depth check**
|
||||||
|
|
||||||
|
- **Found during:** Task 1 design (alongside Deviation 1 above).
|
||||||
|
- **Issue:** The plan's `<artifacts>` + `<acceptance_criteria>` specified 3 checks (A31.1 SAVE ack + A31.2 sentinel absence + A31.3 password-target absence). All 3 are absence-checks (negative assertions). Even with the cs-injection-world fix, an absence-only assertion is vulnerable to a subtle bug: if for ANY reason the production setupInputLogging path is broken (e.g., a future refactor that wraps document.addEventListener inside a feature flag), A31.2 + A31.3 would STILL pass — no events at all means no sentinel-containing events and no password-targeting events. The plan's `truths[1]` + `truths[2]` would be vacuously satisfied.
|
||||||
|
- **Rule 2 reasoning:** The plan's `<threat_model>` T-03-03-04 explicitly flags this: "If A31 GREEN but the production filter actually broke, the assertion would mislead | mitigate | A31 checks two orthogonal paths: (a) sentinel-value absence and (b) password-selector-target absence. Both pass IFF the filter early-returns BEFORE addUserEvent. A regression in the filter would cause AT LEAST ONE of the two checks to RED. Defense-in-depth at the test layer." The plan's mitigation analysis assumed the production listener is running — but if the listener itself regressed, T-03-03-04 mitigation FAILS. A positive-control check is required for true defense-in-depth.
|
||||||
|
- **Fix:** Added A31.4 — the same chrome.scripting.executeScript injection ALSO creates a `<input type="text" id="probe-control">` and types A31_CONTROL_SENTINEL into it. Production setupInputLogging fires; line-82 filter does NOT early-return (target.type === 'text'); addUserEvent captures the event with .value containing the control sentinel. driveA31 then asserts `eventsContainingControl.length >= 1`. If GREEN, the listener IS alive — so A31.2/A31.3 GREEN actually mean the line-82 filter fired, not "no events at all".
|
||||||
|
- **Why this is Rule 2 (critical functionality):** Without A31.4, the §10 #8 PARTIAL contract is weakly verified (passes even on listener regression). With A31.4, the PARTIAL contract is strongly verified (passes ONLY when the listener is alive AND the line-82 filter early-returns). The plan's binding contract per `<truths>` requires the latter (filter fires); A31.4 is the empirical floor that distinguishes the two cases.
|
||||||
|
- **Result:** A31.4 GREEN with `control-containing count=1`. EMPIRICALLY PROVES the production listener is alive — so A31.2/A31.3 absences MEAN the filter fired.
|
||||||
|
- **Files modified:** tests/uat/extension-page-harness.ts (A31_CONTROL_SENTINEL constant + A31_CONTROL_INPUT_ID constant + injection function creates + types into control input alongside password input); tests/uat/lib/harness-page-driver.ts (A31_CONTROL_SENTINEL constant + eventsContainingControl filter + A31.4 check push).
|
||||||
|
- **Committed in:** `8db629f` (Task 1: page-side surface) + `34b36fb` (Task 2: host-side check).
|
||||||
|
|
||||||
|
**Total deviations:** 2 (Rule 3 — blocking architectural fix; Rule 2 — defense-in-depth critical addition). Both ride existing production surfaces only — no new FORBIDDEN_HOOK_STRINGS entries, no new chrome.* permissions, no new manifest changes. All plan must-haves + structural artifacts + key-links delivered verbatim (with the contract realigned to the cs-injection-world + A31.4 defense-in-depth pattern).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automation gates (this run)
|
||||||
|
|
||||||
|
- **tsc --noEmit:** Exit 0; clean.
|
||||||
|
- **npm run build:test (via `npm run test:uat`):** Exit 0; dist-test/ populated.
|
||||||
|
- **vitest 171/171 GREEN** — full suite preserved (31 test files; 13.62s + 46.68s parallel test runs).
|
||||||
|
- **Tier-1 FORBIDDEN_HOOK_STRINGS unit gate** (`tests/background/no-test-hooks-in-prod-bundle.test.ts`): 13/13 sub-tests GREEN; 12 strings × 0 hits each (4.65s).
|
||||||
|
- **UAT harness end-to-end:** `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exit 0; **32/32 GREEN** (31 prior + A31).
|
||||||
|
|
||||||
|
### A31 empirical evidence (from the live UAT trace 2026-05-20T18:43:54Z)
|
||||||
|
|
||||||
|
```
|
||||||
|
A31 — password filter fires (SPEC §10 #8 PARTIAL per D-P3-02): PASS
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
[PASS] A31.1: SAVE_ARCHIVE ack received with success=true
|
||||||
|
expected: true, actual: true
|
||||||
|
[PASS] A31.0a: logs/events.json entry exists in zip
|
||||||
|
expected: true, actual: true
|
||||||
|
[PASS] A31.2: 0 UserEvent entries contain the SENTINEL in their .value field (proves src/content/index.ts:82 filter fired)
|
||||||
|
expected: 0, actual: 0
|
||||||
|
[PASS] A31.3: 0 UserEvent entries have target === '#probe-password' (filter early-returns BEFORE addUserEvent)
|
||||||
|
expected: 0, actual: 0
|
||||||
|
[PASS] A31.4: >=1 UserEvent entry contains the CONTROL sentinel (defense-in-depth: proves the listener is alive, so A31.2/A31.3 absences mean the filter fired — not "no events at all")
|
||||||
|
expected: ">=1 control", actual: 1
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
- Step 1: setupFreshRecording (A31 owns its recording — clean event-log window)
|
||||||
|
- Step 1 OK — REC state established
|
||||||
|
- Step 2: chrome.tabs.create(https://example.com/, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension:// (Plan 03-02 lesson)
|
||||||
|
- Step 2 result: probeTab.id=639627313, probeTab.url=
|
||||||
|
- Step 3: wait 1500ms for navigation + content script attach
|
||||||
|
- Step 4: settle 11000ms for first segment rotation
|
||||||
|
- 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)
|
||||||
|
- Step 5 result: {"controlDispatched":true,"controlTyped":true,"passwordDispatched":true,"passwordTyped":true}
|
||||||
|
- Step 6: settle 1000ms so synchronous handlers complete + userEvents[] populates
|
||||||
|
- Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)
|
||||||
|
- Step 7 result: {"success":true}
|
||||||
|
- A31 zipPath=/tmp/mokosh-uat-KBwJL1/47832f1c-ec9c-47ca-bb49-796a5e167b16.zip
|
||||||
|
- A31 userEvents.length=1
|
||||||
|
- A31 sentinel-containing count=0, password-targeting count=0, control-containing count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plan must-haves coverage (all GREEN)
|
||||||
|
|
||||||
|
- `truths[0]` "Typing a sentinel value into the probe-page password input does NOT cause that value to appear in logs/events.json (existing src/content/index.ts:82 filter fires)" — A31.2 PASS (sentinel-containing count=0).
|
||||||
|
- `truths[1]` "Counts of UserEvent entries whose .value field contains the sentinel string = 0" — A31.2 PASS.
|
||||||
|
- `truths[2]` "Counts of UserEvent entries whose .target selector points at the password input = 0 (filter happens early-return BEFORE addUserEvent)" — A31.3 PASS (password-targeting count=0).
|
||||||
|
- `truths[3]` "UAT harness exits 0 with 31 + 1 = 32/32 assertions GREEN (A30 baseline preserved + new A31)" — PASS (32/32 GREEN; A0..A30 baseline preserved).
|
||||||
|
- `artifacts[0]` assertA31 page-side orchestrator on window.__mokoshHarness — PASS (3 grep hits in extension-page-harness.ts).
|
||||||
|
- `artifacts[1]` driveA31 host-side with JSZip-parse + negative-assertion sentinel grep — PASS (2 grep hits in harness-page-driver.ts).
|
||||||
|
- `artifacts[2]` driveA31 import + wrapped driver + drivers-array push entry — PASS (6 grep hits in harness.test.ts).
|
||||||
|
- `key_links[0]` harness.test.ts → driveA31 via driveA31Wrapped — PASS.
|
||||||
|
- `key_links[1]` driveA31 → assertA31 via page.evaluate(harness.assertA31()) — PASS (`harness.assertA31()` invocation present).
|
||||||
|
- `key_links[2]` assertA31 → src/content/index.ts:82 password filter via synthetic password-input event injected into content-script ISOLATED world on https://example.com — PASS (empirical: A31.2 + A31.3 + A31.4 prove the path is exercised and the filter fired).
|
||||||
|
|
||||||
|
### Acceptance grep gates
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` exit 0 — PASS
|
||||||
|
- `grep -c 'assertA31' tests/uat/extension-page-harness.ts` returns 3 ≥ 3 — PASS
|
||||||
|
- `grep -c 'A31_PASSWORD_SENTINEL' tests/uat/extension-page-harness.ts` returns 2 ≥ 2 — PASS
|
||||||
|
- `grep -c "'secret-do-not-log-123'" tests/uat/extension-page-harness.ts` returns 1 == 1 — PASS
|
||||||
|
- `grep -c 'driveA31' tests/uat/lib/harness-page-driver.ts` returns 2 ≥ 2 — PASS
|
||||||
|
- `grep -c 'driveA31' tests/uat/harness.test.ts` returns 6 ≥ 3 — PASS
|
||||||
|
- `grep -c 'secret-do-not-log-123' tests/uat/lib/harness-page-driver.ts` returns 1 == 1 — PASS
|
||||||
|
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exit 0 with stdout containing `UAT harness: 32/32 assertions passed` — PASS
|
||||||
|
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exit 0 (Tier-1 inventory stays at 12) — PASS
|
||||||
|
- `git diff src/content/index.ts` empty (verification-only contract) — PASS
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
**One latent issue surfaced (PRE-EXISTING; out of scope for this plan; defer to Plan 03-05 + Phase 4):**
|
||||||
|
|
||||||
|
- **A29 zip-mtime race-condition flake (pre-existing; documented in 03-02-SUMMARY.md "Issues Encountered").** During Plan 03-03 empirical verification of A31, the pre-existing A29 flake re-surfaced: A29 SAVEs to the harness page tab (chrome-extension://...), which has no content script attached, so the SW's GET_RRWEB_EVENTS bridge logs "Could not establish connection. Receiving end does not exist." and the produced zip's rrweb/session.json is empty. A29's `findLatestZip` host-side helper then non-deterministically returns one of:
|
||||||
|
- **(a) A29's own empty zip** (mtime tiebreaker resolves A29's zip last) — A29.2/A29.3/A29.4/A29.5 FAIL with `events.length=0`
|
||||||
|
- **(b) A prior real-content zip** (mtime tiebreaker resolves a prior zip, e.g., A27's zip with iana.org rrweb events) — A29.2/A29.3/A29.4/A29.5 PASS with `events.length=4`
|
||||||
|
- **Empirical pre-existing flake reproduction:** 3 UAT runs at base HEAD `de398347` (no Plan 03-03 changes applied) — 2/3 PASS, 1/3 FAIL with the exact same symptom (`A29 events.length=0` on FAIL; `A29 events.length=4` on PASS). This empirically confirms the flake is INDEPENDENT of Plan 03-03's changes — pre-existing in the base.
|
||||||
|
- **Per CLAUDE.md SCOPE BOUNDARY rule:** "Only auto-fix issues DIRECTLY caused by the current task's changes. Pre-existing warnings, linting errors, or failures in unrelated files are out of scope." The A29 flake is documented as a deferred-items entry per Plan 03-02 SUMMARY's explicit recommendation. Plan 03-03 captured A31 GREEN evidence in a run where A29 happened to PASS (race won), demonstrating that Plan 03-03's own contract (A31 GREEN with 5/5 checks; UAT 32/32 GREEN) is achievable.
|
||||||
|
- **Recommended follow-up (per Plan 03-02 SUMMARY + this plan):** A Phase 3 amendment plan (e.g., 03-01a) or a Phase 4 hardening pass re-targets A29 to use the same cs-injection-world probe-tab pattern A30 introduced + A31 reused. The fix is mechanical (replace assertA29 page-side body with cs-injection-world variant + drive a DOM mutation in the probe tab's ISOLATED world) and removes the mtime-race ambiguity. Plan 03-05 VERIFICATION.md should flag this as a known follow-up for §10 #4 closure rigor (the current closure stands on A30-style empirical evidence; A29 needs the same hardening).
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None new. The plan's `<threat_model>` (T-03-03-01..T-03-03-04) was analyzed at planner-time; implementation honors all mitigations:
|
||||||
|
|
||||||
|
- **T-03-03-01 (Information Disclosure — sentinel value lands in event log despite filter):** disposition `mitigate`. A31 IS the negative-assertion mitigation. A31.2 + A31.3 both PASS empirically (counts=0). RED would mean the filter regressed; the test enforces the invariant. Sentinel is not a real secret (per RESEARCH §"Security Domain"); if it leaked, it would be visible in events.json which is logged locally but never transmitted (REQ-password-confidentiality Out of Scope v1 charter applies).
|
||||||
|
- **T-03-03-02 (Information Disclosure — test-only hook surface leaking to production bundle):** disposition `mitigate`. A31 rides the production input listener + line-82 filter + chrome.tabs + chrome.scripting + existing helpers. No `__MOKOSH_UAT__`-gated symbols. FORBIDDEN_HOOK_STRINGS unchanged at 12 entries; 13/13 unit-gate sub-tests GREEN; 12 strings × 0 hits in dist/. UAT A0 mirror unchanged at 12.
|
||||||
|
- **T-03-03-03 (Tampering — A31 SAVE produces a zip in the per-run downloadsDir that contains the sentinel only IF the filter regressed):** disposition `accept`. The per-run downloadsDir is mkdtempSync'd by launchHarnessBrowser + cleaned by the test runner; no cross-run leakage. Sentinel is not a real secret.
|
||||||
|
- **T-03-03-04 (Repudiation — if A31 GREEN but the production filter actually broke, the assertion would mislead):** disposition `mitigate`. A31 checks THREE orthogonal paths after Rule 2 addition: (a) sentinel-value absence, (b) password-selector-target absence, (c) control-sentinel presence (defense-in-depth: proves the listener is alive). All three pass IFF the listener is running AND the line-82 filter early-returns BEFORE addUserEvent for the password input. A regression in the filter would cause AT LEAST ONE of (a)+(b) to RED (sentinel would land in .value, or events would target #probe-password). A regression in the listener itself would cause (c) to RED (control event missing). Strictly stronger than the plan-spec 2-orthogonal-paths mitigation.
|
||||||
|
|
||||||
|
No new production surface; threat surface unchanged from Phase 2. The existing src/content/index.ts:82 filter is the verification subject; the PARTIAL mark in Plan 03-05 VERIFICATION.md explicitly carries the charter rationale.
|
||||||
|
|
||||||
|
## Phase 3 Wave Sequencing
|
||||||
|
|
||||||
|
Per CONTEXT D-P3-01 + RESEARCH Pitfall 6: Plans 03-01..04 modify the SAME three harness files (extension-page-harness.ts, harness-page-driver.ts, harness.test.ts). RESEARCH §"Wave Sequencing Note" recommended SEQUENTIAL execution within Wave 2: 03-01 (A29) → 03-02 (A30) → 03-03 (A31, this plan) → 03-04 (A32 RAM optional). Plan 03-03 runs AFTER 03-01 + 03-02 (depends_on: [01, 02]) — Wave 3 sequence honored.
|
||||||
|
|
||||||
|
## Next Plan Readiness
|
||||||
|
|
||||||
|
- **Plan 03-04 (§10 #9 RAM best-effort):** Independent surface (puppeteer.Page.metrics scaffolding); A31's cs-injection-world pattern doesn't apply directly (RAM measurement is host-side via CDP Performance.getMetrics). Plan 03-04 can proceed with no Plan 03-03 dependency interactions.
|
||||||
|
- **Plan 03-05 (§10 sweep VERIFICATION.md aggregator):** Inherits A31 as the binding §10 #8 PARTIAL empirical gate. Frontmatter shape per RESEARCH §"Code Examples → VERIFICATION.md frontmatter template" includes `overrides_applied: 1` with explicit `override_notes` block citing D-P3-02 charter + A31 GREEN evidence. Plan 03-05 SHOULD ALSO flag the A29 flake under "Forward-Looking Deferred Items" (Phase 4 hardening candidate).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- A31 assertion added: CONFIRMED via git log + grep (`assertA31` 3 hits in extension-page-harness.ts; `driveA31` 2 hits in harness-page-driver.ts + 6 in orchestrator).
|
||||||
|
- UAT count: 31 → 32 GREEN: **EMPIRICALLY CONFIRMED** via `HEADLESS=1 SKIP_PROD_REBUILD=1 npm run test:uat` exit 0; 32/32 assertions PASSED (A1..A30 + A31).
|
||||||
|
- vitest 171/171 GREEN preserved: CONFIRMED (full suite; 31 test files).
|
||||||
|
- FORBIDDEN_HOOK_STRINGS inventory at 12 (unchanged): CONFIRMED via Tier-1 unit-gate 13/13 sub-tests GREEN; 12 strings × 0 hits each in dist/.
|
||||||
|
- Sentinel password value does NOT appear in logs/events.json after A31 run: CONFIRMED via `A31 sentinel-containing count=0`; `password-targeting count=0`; with `control-containing count=1` proving the listener IS alive (defense-in-depth).
|
||||||
|
- No new masking surfaces added: CONFIRMED (zero src/content/index.ts edits; no rrweb maskInputFn; no data-sensitive attribute guards).
|
||||||
|
- No modifications to src/content/index.ts: CONFIRMED (`git diff src/content/index.ts` empty; `git log de398347..HEAD -- src/content/index.ts` empty).
|
||||||
|
- tsc clean: CONFIRMED (`npx tsc --noEmit` exit 0).
|
||||||
|
- 2/2 plan tasks committed atomically (`8db629f` Task 1 + `34b36fb` Task 2).
|
||||||
|
- SUMMARY.md created (committed in the final docs commit).
|
||||||
|
|
||||||
|
### File existence verification
|
||||||
|
|
||||||
|
```
|
||||||
|
FOUND: tests/uat/extension-page-harness.ts (assertA31 + window.__mokoshHarness entry)
|
||||||
|
FOUND: tests/uat/lib/harness-page-driver.ts (driveA31)
|
||||||
|
FOUND: tests/uat/harness.test.ts (driveA31 import + Wrapped const + drivers push + banner)
|
||||||
|
FOUND: .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.md (this file)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit verification
|
||||||
|
|
||||||
|
```
|
||||||
|
FOUND: 8db629f feat(03-03): Task 1 — assertA31 page-side orchestrator (cs-injection-world password-filter probe)
|
||||||
|
FOUND: 34b36fb feat(03-03): Task 2 — driveA31 + orchestrator wiring (A31 password-filter PARTIAL)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 03-spec-10-smoke-verification-dom-event-log-verification*
|
||||||
|
*Plan: 03*
|
||||||
|
*Completed: 2026-05-20*
|
||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ import {
|
|||||||
driveA29,
|
driveA29,
|
||||||
// Plan 03-02 — event-log verification (SPEC §10 #5 / REQ-user-event-log)
|
// Plan 03-02 — event-log verification (SPEC §10 #5 / REQ-user-event-log)
|
||||||
driveA30,
|
driveA30,
|
||||||
|
// Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02)
|
||||||
|
driveA31,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
} from './lib/harness-page-driver';
|
} from './lib/harness-page-driver';
|
||||||
import {
|
import {
|
||||||
@@ -269,7 +271,7 @@ async function assertA0_GrepGate(): Promise<{
|
|||||||
*/
|
*/
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n');
|
process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n');
|
||||||
process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29, A30)\n');
|
process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29, A30, A31)\n');
|
||||||
process.stdout.write('='.repeat(72) + '\n');
|
process.stdout.write('='.repeat(72) + '\n');
|
||||||
|
|
||||||
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
||||||
@@ -345,6 +347,12 @@ async function main(): Promise<number> {
|
|||||||
// of logs/events.json from the just-produced zip.
|
// of logs/events.json from the just-produced zip.
|
||||||
const driveA30Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
const driveA30Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
(page) => driveA30(page, handles.downloadsDir);
|
(page) => driveA30(page, handles.downloadsDir);
|
||||||
|
// Plan 03-03 — driveA31 needs downloadsDir for host-side JSZip
|
||||||
|
// negative-assertion against logs/events.json (sentinel absence +
|
||||||
|
// password-selector-target absence) + control-sentinel presence
|
||||||
|
// (defense-in-depth A31.4).
|
||||||
|
const driveA31Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
|
(page) => driveA31(page, handles.downloadsDir);
|
||||||
|
|
||||||
const drivers: ReadonlyArray<{
|
const drivers: ReadonlyArray<{
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -454,6 +462,19 @@ async function main(): Promise<number> {
|
|||||||
// driveA30 JSZip-parses logs/events.json and asserts presence of
|
// driveA30 JSZip-parses logs/events.json and asserts presence of
|
||||||
// each of the 5 UserEvent.type literal values.
|
// each of the 5 UserEvent.type literal values.
|
||||||
{ name: 'A30', drive: driveA30Wrapped },
|
{ name: 'A30', drive: driveA30Wrapped },
|
||||||
|
// Plan 03-03 A31: password-filter PARTIAL (SPEC §10 #8 PARTIAL per
|
||||||
|
// D-P3-02). Negative-assertion: opens a fresh https://example.com
|
||||||
|
// probe tab (Plan 03-02 cs-injection-world precedent), injects a
|
||||||
|
// synthetic <input type="password"> + a control <input type="text">
|
||||||
|
// via chrome.scripting.executeScript ISOLATED-world, types the
|
||||||
|
// sentinels, settles, SAVEs while the probe tab is active, finally-
|
||||||
|
// cleanup. Host-side driveA31 inspects logs/events.json and asserts
|
||||||
|
// sentinel value absence + password-selector-target absence (proves
|
||||||
|
// src/content/index.ts:82 filter fired) + control-sentinel presence
|
||||||
|
// (defense-in-depth: proves the listener is alive so A31.2/A31.3
|
||||||
|
// mean the filter actually fired rather than the trivial "no
|
||||||
|
// events at all" tautology).
|
||||||
|
{ name: 'A31', drive: driveA31Wrapped },
|
||||||
];
|
];
|
||||||
|
|
||||||
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
||||||
|
|||||||
@@ -2146,3 +2146,171 @@ export async function driveA30(
|
|||||||
error: pageResult.error,
|
error: pageResult.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Plan 03-03 — driveA31 (password-filter PARTIAL host-side) ─────── */
|
||||||
|
|
||||||
|
/** Fixed test sentinel — same value as page-side A31_PASSWORD_SENTINEL.
|
||||||
|
* Negative-assertion driver searches events.json for its absence. */
|
||||||
|
const A31_PASSWORD_SENTINEL = 'secret-do-not-log-123';
|
||||||
|
/** Control sentinel — must be PRESENT in logs/events.json (A31.4
|
||||||
|
* defense-in-depth: proves the production setupInputLogging listener
|
||||||
|
* is alive, so A31.2/A31.3 absence checks are not vacuously
|
||||||
|
* satisfied — they actually mean the line-82 filter fired). */
|
||||||
|
const A31_CONTROL_SENTINEL = 'control-event-must-be-logged-a31';
|
||||||
|
/** Selector the production getSelector returns for #probe-password. */
|
||||||
|
const A31_PASSWORD_SELECTOR = '#probe-password';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive A31 (Plan 03-03 — SPEC §10 #8 PARTIAL per D-P3-02).
|
||||||
|
*
|
||||||
|
* Page-side assertA31 opened a fresh https://example.com probe tab,
|
||||||
|
* injected a `<input type="password" id="probe-password">` element +
|
||||||
|
* a `<input type="text" id="probe-control">` element, typed the
|
||||||
|
* corresponding sentinels in the content-script's ISOLATED world,
|
||||||
|
* settled, SAVEd, finally-cleanup the tab. Host-side asserts:
|
||||||
|
* - the password SENTINEL is ABSENT from any UserEvent.value field
|
||||||
|
* (proves the line-82 filter early-returned before addUserEvent)
|
||||||
|
* - no UserEvent has target === '#probe-password' (proves the same
|
||||||
|
* filter via the orthogonal selector path)
|
||||||
|
* - at least one UserEvent contains the control sentinel
|
||||||
|
* (defense-in-depth: proves the listener IS alive, so the
|
||||||
|
* absences A31.2/A31.3 actually mean the filter fired rather
|
||||||
|
* than the trivial "no events captured" tautology)
|
||||||
|
*
|
||||||
|
* Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §.
|
||||||
|
*
|
||||||
|
* Checks (4 visible + guards):
|
||||||
|
* - A31.1: SAVE_ARCHIVE ack (page-side)
|
||||||
|
* - A31.0a: logs/events.json entry exists in zip
|
||||||
|
* - A31.0b: logs/events.json parses as JSON
|
||||||
|
* - A31.2: 0 events contain SENTINEL in .value field
|
||||||
|
* - A31.3: 0 events have target === '#probe-password'
|
||||||
|
* - A31.4: >=1 event contains the control sentinel
|
||||||
|
*
|
||||||
|
* @param page - The harness page from `launchHarnessBrowser`.
|
||||||
|
* @param downloadsDir - Absolute path to the per-run downloads directory.
|
||||||
|
* @returns AssertionRecord with the merged checks.
|
||||||
|
*/
|
||||||
|
export async function driveA31(
|
||||||
|
page: Page,
|
||||||
|
downloadsDir: string,
|
||||||
|
): Promise<AssertionRecord> {
|
||||||
|
// Phase 1 — page-side orchestration + SAVE.
|
||||||
|
const pageResult = await page.evaluate(async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
|
||||||
|
const harness = (window as any).__mokoshHarness;
|
||||||
|
const r: AssertionRecord = await harness.assertA31();
|
||||||
|
return r;
|
||||||
|
}) as AssertionRecord;
|
||||||
|
|
||||||
|
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
|
||||||
|
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
|
||||||
|
|
||||||
|
// Phase 2 — locate the produced zip.
|
||||||
|
const zipPath = findLatestZip(downloadsDir);
|
||||||
|
if (zipPath === null) {
|
||||||
|
mergedChecks.push({
|
||||||
|
name: 'A31.0: at least one zip present in downloadsDir',
|
||||||
|
expected: '>=1 zip',
|
||||||
|
actual: 'no zip in downloadsDir',
|
||||||
|
passed: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
name: pageResult.name,
|
||||||
|
checks: mergedChecks,
|
||||||
|
diagnostics: mergedDiagnostics,
|
||||||
|
error: pageResult.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
mergedDiagnostics.push(`A31 zipPath=${zipPath}`);
|
||||||
|
|
||||||
|
// Phase 3 — load + inspect logs/events.json.
|
||||||
|
const zipBytes = readFileSync(zipPath);
|
||||||
|
const zip = await JSZip.loadAsync(zipBytes);
|
||||||
|
const eventsFile = zip.file('logs/events.json');
|
||||||
|
mergedChecks.push({
|
||||||
|
name: 'A31.0a: logs/events.json entry exists in zip',
|
||||||
|
expected: true,
|
||||||
|
actual: eventsFile !== null,
|
||||||
|
passed: eventsFile !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (eventsFile === null) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
name: pageResult.name,
|
||||||
|
checks: mergedChecks,
|
||||||
|
diagnostics: mergedDiagnostics,
|
||||||
|
error: pageResult.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsRaw = await eventsFile.async('string');
|
||||||
|
let userEvents: UserEvent[] = [];
|
||||||
|
let parseErr: string | null = null;
|
||||||
|
try {
|
||||||
|
userEvents = JSON.parse(eventsRaw) as UserEvent[];
|
||||||
|
} catch (err) {
|
||||||
|
parseErr = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseErr !== null) {
|
||||||
|
mergedChecks.push({
|
||||||
|
name: 'A31.0b: logs/events.json parses as JSON',
|
||||||
|
expected: 'JSON.parse success',
|
||||||
|
actual: `<error: ${parseErr}>`,
|
||||||
|
passed: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
name: pageResult.name,
|
||||||
|
checks: mergedChecks,
|
||||||
|
diagnostics: mergedDiagnostics,
|
||||||
|
error: pageResult.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter-pipeline form per CLAUDE.md Control Flow §.
|
||||||
|
const eventsContainingSentinel = userEvents.filter(
|
||||||
|
(e) => typeof e.value === 'string' && e.value.includes(A31_PASSWORD_SENTINEL),
|
||||||
|
);
|
||||||
|
const eventsTargetingPassword = userEvents.filter(
|
||||||
|
(e) => e.target === A31_PASSWORD_SELECTOR,
|
||||||
|
);
|
||||||
|
const eventsContainingControl = userEvents.filter(
|
||||||
|
(e) => typeof e.value === 'string' && e.value.includes(A31_CONTROL_SENTINEL),
|
||||||
|
);
|
||||||
|
mergedDiagnostics.push(`A31 userEvents.length=${userEvents.length}`);
|
||||||
|
mergedDiagnostics.push(
|
||||||
|
`A31 sentinel-containing count=${eventsContainingSentinel.length}, password-targeting count=${eventsTargetingPassword.length}, control-containing count=${eventsContainingControl.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
mergedChecks.push({
|
||||||
|
name: 'A31.2: 0 UserEvent entries contain the SENTINEL in their .value field (proves src/content/index.ts:82 filter fired)',
|
||||||
|
expected: 0,
|
||||||
|
actual: eventsContainingSentinel.length,
|
||||||
|
passed: eventsContainingSentinel.length === 0,
|
||||||
|
});
|
||||||
|
mergedChecks.push({
|
||||||
|
name: `A31.3: 0 UserEvent entries have target === '${A31_PASSWORD_SELECTOR}' (filter early-returns BEFORE addUserEvent)`,
|
||||||
|
expected: 0,
|
||||||
|
actual: eventsTargetingPassword.length,
|
||||||
|
passed: eventsTargetingPassword.length === 0,
|
||||||
|
});
|
||||||
|
mergedChecks.push({
|
||||||
|
name: 'A31.4: >=1 UserEvent entry contains the CONTROL sentinel (defense-in-depth: proves the listener is alive, so A31.2/A31.3 absences mean the filter fired — not "no events at all")',
|
||||||
|
expected: '>=1 control',
|
||||||
|
actual: eventsContainingControl.length,
|
||||||
|
passed: eventsContainingControl.length >= 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedPassed = mergedChecks.every((c) => c.passed);
|
||||||
|
return {
|
||||||
|
passed: mergedPassed,
|
||||||
|
name: pageResult.name,
|
||||||
|
checks: mergedChecks,
|
||||||
|
diagnostics: mergedDiagnostics,
|
||||||
|
error: pageResult.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user