5 plans across 5 waves (Wave 2 sequential per RESEARCH Pitfall 6 file overlap): - 03-01 Wave 1: rrweb DOM verification harness extension (A29; REQ-rrweb-dom-buffer; §10 #4) - 03-02 Wave 2: event-log verification harness extension (A30; REQ-user-event-log; §10 #5) - 03-03 Wave 3: §10 #8 password-filter PARTIAL verification (A31; D-P3-02 charter) - 03-04 Wave 4: §10 #9 RAM ceiling best-effort + Page.metrics scaffolding (A32; D-P3-04) - 03-05 Wave 5: §10 sweep VERIFICATION.md + REQUIREMENTS/ROADMAP/STATE marker flips (REQ-install-clean + REQ-rrweb-dom-buffer + REQ-user-event-log) Each plan has: - frontmatter (wave + depends_on + files_modified + autonomous + requirements + tags + must_haves) - tasks with mandatory <read_first> + <acceptance_criteria> + concrete <action> - <threat_model> block per security gate - Validation map row(s) added to 03-VALIDATION.md (10 tasks total) Expected UAT growth: 29/29 → 33/33 GREEN (A29-A32 + 03-05 docs). Expected vitest baseline preserved: 171/171. Expected Tier-1 FORBIDDEN_HOOK_STRINGS: 12 (A29+ ride production surfaces only). ROADMAP.md Phase 3 entry replaces "Plans: TBD" with full 5-plan list. VALIDATION.md status: planner_filled (nyquist_compliant: true; wave_0_complete: true). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
610 lines
27 KiB
Markdown
610 lines
27 KiB
Markdown
---
|
||
phase: 03
|
||
slug: spec-10-smoke-verification-dom-event-log-verification
|
||
plan: 03
|
||
type: execute
|
||
wave: 3
|
||
depends_on:
|
||
- 01
|
||
- 02
|
||
files_modified:
|
||
- tests/uat/extension-page-harness.ts
|
||
- tests/uat/lib/harness-page-driver.ts
|
||
- tests/uat/harness.test.ts
|
||
autonomous: true
|
||
requirements: []
|
||
tags:
|
||
- uat-harness
|
||
- a31
|
||
- password-filter
|
||
- spec-10-8-partial
|
||
- approach-b
|
||
- negative-assertion
|
||
- charter-d-p3-02
|
||
user_setup: []
|
||
must_haves:
|
||
truths:
|
||
- "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)"
|
||
- "Counts of UserEvent entries whose .value field contains the sentinel string = 0"
|
||
- "Counts of UserEvent entries whose .target selector points at the password input = 0 (filter happens early-return BEFORE addUserEvent)"
|
||
- "UAT harness exits 0 with 31 + 1 = 32/32 assertions GREEN (A30 baseline preserved + new A31)"
|
||
artifacts:
|
||
- path: "tests/uat/extension-page-harness.ts"
|
||
provides: "assertA31 page-side orchestrator: types sentinel into #probe-password, runs setupFreshRecording + SAVE"
|
||
contains: "assertA31"
|
||
- path: "tests/uat/lib/harness-page-driver.ts"
|
||
provides: "driveA31 host-side: JSZip-parse logs/events.json + negative-assertion sentinel grep"
|
||
contains: "driveA31"
|
||
- path: "tests/uat/harness.test.ts"
|
||
provides: "driveA31 import + wrapped driver + drivers-array push entry"
|
||
contains: "driveA31"
|
||
key_links:
|
||
- from: "tests/uat/lib/harness-page-driver.ts driveA31"
|
||
to: "tests/uat/extension-page-harness.ts assertA31"
|
||
via: "page.evaluate(() => window.__mokoshHarness.assertA31())"
|
||
pattern: "harness.assertA31\\(\\)"
|
||
- from: "tests/uat/extension-page-harness.ts assertA31"
|
||
to: "src/content/index.ts:82 password filter (READ-ONLY VERIFICATION SUBJECT)"
|
||
via: "synthetic password-input event triggers setupInputLogging filter early-return BEFORE addUserEvent"
|
||
pattern: "if \\(target.type === 'password'\\)"
|
||
---
|
||
|
||
<objective>
|
||
Extend the UAT harness with A31 — empirical PARTIAL verification of SPEC
|
||
§10 #8. Per D-P3-02 locked decision: REQ-password-confidentiality is
|
||
Out of Scope v1; full rrweb v2 maskInputFn + data-sensitive guards are
|
||
deferred to Phase 4. Plan 03-03 confirms the EXISTING minimum filter at
|
||
`src/content/index.ts:82` (`if (target.type === 'password') return;`)
|
||
fires — sentinel string typed into the probe-page `<input type="password">`
|
||
MUST NOT appear in `logs/events.json`. This is a negative-assertion gate.
|
||
|
||
Purpose: Honors the charter literally — verify the existing
|
||
defense-in-depth without expanding scope; the PARTIAL mark in Plan
|
||
03-05 VERIFICATION.md will cite this A31 GREEN + the charter rationale.
|
||
|
||
Output: A31 assertion with 3 host-side checks (SAVE ack + sentinel
|
||
absent from events.json .value fields + zero events targeting the
|
||
password selector); UAT count 31 → 32 GREEN.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/STATE.md
|
||
@.planning/REQUIREMENTS.md
|
||
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-CONTEXT.md
|
||
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-RESEARCH.md
|
||
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md
|
||
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-01-PLAN.md
|
||
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-PLAN.md
|
||
@src/content/index.ts
|
||
@src/shared/types.ts
|
||
|
||
<interfaces>
|
||
<!-- Key contracts the executor needs. Extracted from existing code. -->
|
||
|
||
From src/content/index.ts (lines 77-96; READ-ONLY — verification subject):
|
||
|
||
// setupInputLogging
|
||
function setupInputLogging() {
|
||
document.addEventListener('input', (event) => {
|
||
const target = event.target as HTMLInputElement;
|
||
|
||
// Пропускаем пароли (line 81 comment; "skip passwords")
|
||
if (target.type === 'password') { // LINE 82 — the filter
|
||
return; // LINE 83 — early-return BEFORE addUserEvent
|
||
}
|
||
|
||
const selector = getSelector(target);
|
||
addUserEvent({ // LINE 88 — never reached for password inputs
|
||
timestamp: Date.now(),
|
||
type: 'input',
|
||
target: selector,
|
||
value: target.value.substring(0, 200),
|
||
url: window.location.href
|
||
});
|
||
}, true);
|
||
}
|
||
|
||
From src/shared/types.ts (lines 124-131):
|
||
|
||
export interface UserEvent {
|
||
timestamp: number;
|
||
type: 'click' | 'input' | 'navigation' | 'js_error' | 'network_error';
|
||
target: string; // CSS selector
|
||
value?: string; // What the user typed (up to 200 chars; omitted for passwords)
|
||
url: string;
|
||
meta?: Record<string, unknown>;
|
||
}
|
||
|
||
From Plan 03-01: probe HTML in extension-page-harness.html provides
|
||
`<input type="password" id="probe-password" data-test="probe-password" />`.
|
||
The id-based selector means getSelector(target) returns `#probe-password`
|
||
for any logged event on this element (per src/content/index.ts:241-243).
|
||
</interfaces>
|
||
|
||
# Plan Anchors
|
||
|
||
- **Sequential wave assignment (per RESEARCH Pitfall 6 + file-overlap rule):** Plan 03-03 lives in wave 3
|
||
modifies the SAME 3 harness files as Plans 03-01 + 03-02. depends_on:
|
||
[01, 02] enforces sequential ordering.
|
||
- **Negative-assertion pattern (per RESEARCH Pattern 3 + Plan 02-04
|
||
A27.7/A27.8 absence-check precedent):** The contract is
|
||
`userEvents.filter(e => e.value contains SENTINEL).length === 0`.
|
||
GREEN A31 = filter fired; RED A31 = filter regressed.
|
||
- **Sentinel is a fixed test constant, not a real secret:** Per RESEARCH
|
||
Security Domain table, `secret-do-not-log-123` is a probe sentinel;
|
||
logging it would itself trigger an explicit RED. No PII.
|
||
- **Targeting via id `#probe-password`:** the production getSelector at
|
||
src/content/index.ts:240 returns `#${element.id}` when an id is
|
||
present, so any event on the password input would have
|
||
`target === '#probe-password'`. A31.3 asserts zero such entries.
|
||
- **Page-side approach with native dispatch:** set `.value` directly +
|
||
dispatch `Event('input', { bubbles: true })` → fires production
|
||
listener at line 78 → filter at line 82 early-returns.
|
||
- **Probe HTML reuse:** Plan 03-01 added the password input; Plan 03-03
|
||
reuses it; no new HTML.
|
||
- **PARTIAL mark scope (D-P3-02 — NOT in scope):** rrweb v2 maskInputFn,
|
||
data-sensitive guards, full §10 #8 closure deferred to Phase 4.
|
||
- **FORBIDDEN_HOOK_STRINGS lockstep:** A31 rides production listeners +
|
||
existing helpers. Tier-1 inventory stays at 12 entries.
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 1: Add assertA31 page-side orchestrator (sentinel-into-password trigger + SAVE)</name>
|
||
<files>tests/uat/extension-page-harness.ts</files>
|
||
<read_first>
|
||
- tests/uat/extension-page-harness.ts where Plan 03-02 added assertA30 (study its shape; new assertA31 appends after it)
|
||
- tests/uat/extension-page-harness.html (verify Plan 03-01 added the password input with id=probe-password)
|
||
- src/content/index.ts lines 77-96 (the exact filter being verified)
|
||
</read_first>
|
||
<behavior>
|
||
Adds module-local constants for A31 and the page-side assertA31
|
||
function that:
|
||
- Step 1: setupFreshRecording (clean event-log window)
|
||
- Step 2: settle one segment
|
||
- Step 3: set #probe-password.value to SENTINEL + dispatch input event (fires production listener; filter early-returns)
|
||
- Step 4: settle synchronous handler completion
|
||
- Step 5: dispatch SAVE_ARCHIVE
|
||
- Push A31.1 (SAVE ack)
|
||
- Registers assertA31 on declare global + __mokoshHarness object literal
|
||
- Updates trailing console.log to mention Plan 03-03: A31
|
||
</behavior>
|
||
<action>
|
||
1. Open `tests/uat/extension-page-harness.ts`. Locate the assertA30 block added by Plan 03-02 and the `getManifestVersion` declaration following it.
|
||
2. Insert the new assertA31 block AFTER assertA30 (BEFORE getManifestVersion). Use this concrete code:
|
||
|
||
```typescript
|
||
/* ─── 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.
|
||
*
|
||
* FORBIDDEN_HOOK_STRINGS impact: NONE. Tier-1 inventory stays at 12.
|
||
*/
|
||
|
||
/** 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 = 500;
|
||
/** 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';
|
||
/** 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';
|
||
|
||
/**
|
||
* A31 — Password-filter PARTIAL empirical (SPEC §10 #8 PARTIAL per D-P3-02).
|
||
*
|
||
* Types the sentinel string into the probe-page password input then
|
||
* SAVEs. Host-side driveA31 inspects logs/events.json and asserts
|
||
* absence of (a) the sentinel value and (b) any entries targeting
|
||
* the password selector.
|
||
*
|
||
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
|
||
* driveA31 appends 2 negative-assertion checks.
|
||
*/
|
||
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: [],
|
||
};
|
||
|
||
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: settle ${A31_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
||
await new Promise((r) => setTimeout(r, A31_SEGMENT_SETTLE_MS));
|
||
|
||
diag(result, `Step 3: type SENTINEL into ${A31_PASSWORD_SELECTOR} + dispatch input event`);
|
||
const pwInput = document.querySelector<HTMLInputElement>(A31_PASSWORD_SELECTOR);
|
||
if (pwInput === null) {
|
||
throw new Error(`A31 trigger failed: ${A31_PASSWORD_SELECTOR} not found in DOM (probe HTML regression from Plan 03-01)`);
|
||
}
|
||
pwInput.value = A31_PASSWORD_SENTINEL;
|
||
pwInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
diag(result, 'Step 3 OK — sentinel typed; production filter at src/content/index.ts:82 should have early-returned');
|
||
|
||
diag(result, `Step 4: settle ${A31_TRIGGER_SETTLE_MS}ms so synchronous handlers complete`);
|
||
await new Promise((r) => setTimeout(r, A31_TRIGGER_SETTLE_MS));
|
||
|
||
diag(result, 'Step 5: dispatch SAVE_ARCHIVE');
|
||
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
|
||
{ type: 'SAVE_ARCHIVE' },
|
||
A31_SAVE_ARCHIVE_TIMEOUT_MS,
|
||
'SAVE_ARCHIVE (A31)',
|
||
);
|
||
diag(result, `Step 5 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}`);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
3. In the `declare global { interface Window { __mokoshHarness: { ... } } }` block, AFTER the assertA30 entry from Plan 03-02 and BEFORE `getManifestVersion`, insert:
|
||
|
||
```typescript
|
||
// Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02)
|
||
assertA31: () => Promise<AssertionResult>;
|
||
```
|
||
|
||
4. In the `window.__mokoshHarness = { ... }` object literal, AFTER `assertA30,` and BEFORE `getManifestVersion,` insert:
|
||
|
||
```typescript
|
||
assertA31,
|
||
```
|
||
|
||
5. Update the closing `console.log(...)` line to append `+ Plan 03-03: A31`. Concrete replacement:
|
||
|
||
```typescript
|
||
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)');
|
||
```
|
||
|
||
6. Run `npx tsc --noEmit`. Expected: clean.
|
||
</action>
|
||
<verify>
|
||
<automated>npx tsc --noEmit; T=$(grep -c "assertA31" tests/uat/extension-page-harness.ts); test "$T" -ge 3 && S=$(grep -c "A31_PASSWORD_SENTINEL" tests/uat/extension-page-harness.ts); test "$S" -ge 2 && grep -q "'secret-do-not-log-123'" tests/uat/extension-page-harness.ts</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- `npx tsc --noEmit` exits 0.
|
||
- `grep -c 'assertA31' tests/uat/extension-page-harness.ts` returns >=3 (function definition + declare global entry + object literal entry).
|
||
- `grep -c 'A31_PASSWORD_SENTINEL' tests/uat/extension-page-harness.ts` returns >=2 (declaration + usage).
|
||
- `grep -c \"'secret-do-not-log-123'\" tests/uat/extension-page-harness.ts` returns exactly 1.
|
||
- Existing Plan 03-02 assertA30 entry still present.
|
||
</acceptance_criteria>
|
||
<done>
|
||
Page-side assertA31 types sentinel into the password input + SAVEs; registered on __mokoshHarness; ready for Task 2 host-side driveA31 negative-assertion.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 2: Add driveA31 host-side (sentinel-absence grep) + orchestrator wiring</name>
|
||
<files>tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
|
||
<read_first>
|
||
- tests/uat/lib/harness-page-driver.ts where Plan 03-02 added driveA30 (same UserEvent import; same 3-phase shape)
|
||
- tests/uat/harness.test.ts where Plan 03-02 added driveA30 wrapping + drivers entry
|
||
- .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md §"Negative-assertion pattern (Plan 03-03 password sentinel grep)"
|
||
</read_first>
|
||
<behavior>
|
||
Host-side (`tests/uat/lib/harness-page-driver.ts`):
|
||
- The `UserEvent` type import added by Plan 03-02 is reused (no new import needed).
|
||
- Adds `export async function driveA31(page: Page, downloadsDir: string): Promise<AssertionRecord>`:
|
||
- Phase 1 — page.evaluate harness.assertA31()
|
||
- Phase 2 — findLatestZip; A31.0 if null
|
||
- Phase 3 — JSZip.loadAsync; read logs/events.json; A31.0a if absent
|
||
- Parse as UserEvent[]; A31.0b on JSON parse failure
|
||
- Push check A31.2: events whose .value contains SENTINEL = 0
|
||
(negative-assertion proves the filter fired)
|
||
- Push check A31.3: events with target === '#probe-password' = 0
|
||
(filter early-returns BEFORE addUserEvent)
|
||
- Diagnostic includes count of total userEvents + count of events that match #probe-password selector + count of events whose value contains sentinel
|
||
- Filter-pipeline form (no `continue`).
|
||
|
||
Orchestrator (`tests/uat/harness.test.ts`):
|
||
- Adds `driveA31,` to import block (after `driveA30,`).
|
||
- Adds `driveA31Wrapped` const after driveA30Wrapped.
|
||
- Adds `{ name: 'A31', drive: driveA31Wrapped },` to drivers array after the A30 entry.
|
||
- Updates orchestrator banner to append `, A31`.
|
||
</behavior>
|
||
<action>
|
||
1. Open `tests/uat/lib/harness-page-driver.ts`. At the end of the file (AFTER driveA30 added by Plan 03-02), append:
|
||
|
||
```typescript
|
||
|
||
/* ─── 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';
|
||
/** 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 typed SENTINEL into the password input then
|
||
* SAVEd. Host-side asserts that:
|
||
* - the sentinel string 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)
|
||
*
|
||
* Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §.
|
||
*
|
||
* Checks (3 total):
|
||
* - A31.1: SAVE_ARCHIVE ack (page-side)
|
||
* - A31.2: 0 events contain SENTINEL in .value field
|
||
* - A31.3: 0 events have target === '#probe-password'
|
||
*
|
||
* @param page - The harness page from `launchHarnessBrowser`.
|
||
* @param downloadsDir - Absolute path to the per-run downloads directory.
|
||
* @returns AssertionRecord with 3 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,
|
||
};
|
||
}
|
||
|
||
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,
|
||
);
|
||
mergedDiagnostics.push(`A31 userEvents.length=${userEvents.length}`);
|
||
mergedDiagnostics.push(
|
||
`A31 sentinel-containing count=${eventsContainingSentinel.length}, password-targeting count=${eventsTargetingPassword.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,
|
||
});
|
||
|
||
const mergedPassed = mergedChecks.every((c) => c.passed);
|
||
return {
|
||
passed: mergedPassed,
|
||
name: pageResult.name,
|
||
checks: mergedChecks,
|
||
diagnostics: mergedDiagnostics,
|
||
error: pageResult.error,
|
||
};
|
||
}
|
||
```
|
||
|
||
2. Open `tests/uat/harness.test.ts`. In the import block from `./lib/harness-page-driver`, AFTER `driveA30,` and BEFORE `getManifestVersion,` add:
|
||
|
||
```typescript
|
||
// Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02)
|
||
driveA31,
|
||
```
|
||
|
||
3. AFTER the `driveA30Wrapped` const (added by Plan 03-02), add:
|
||
|
||
```typescript
|
||
// Plan 03-03 — driveA31 needs downloadsDir for host-side JSZip
|
||
// negative-assertion against logs/events.json.
|
||
const driveA31Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||
(page) => driveA31(page, handles.downloadsDir);
|
||
```
|
||
|
||
4. In the drivers array, AFTER the `{ name: 'A30', ... }` entry, add:
|
||
|
||
```typescript
|
||
// Plan 03-03 A31: password-filter PARTIAL (SPEC §10 #8 PARTIAL per
|
||
// D-P3-02). Negative-assertion: types sentinel into password input;
|
||
// host-side asserts absence from logs/events.json (proves the
|
||
// existing src/content/index.ts:82 filter fires).
|
||
{ name: 'A31', drive: driveA31Wrapped },
|
||
```
|
||
|
||
5. Update the orchestrator banner line (line 268) to append `, A31`:
|
||
|
||
```typescript
|
||
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');
|
||
```
|
||
|
||
6. Run `npx tsc --noEmit`. Expected: clean.
|
||
7. Run `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat`. Expected: `32/32 GREEN`.
|
||
</action>
|
||
<verify>
|
||
<automated>npx tsc --noEmit; D=$(grep -c "driveA31" tests/uat/lib/harness-page-driver.ts); test "$D" -ge 2 && H=$(grep -c "driveA31" tests/uat/harness.test.ts); test "$H" -ge 3 && HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- `npx tsc --noEmit` exits 0.
|
||
- `grep -c 'driveA31' tests/uat/lib/harness-page-driver.ts` returns >=2.
|
||
- `grep -c 'driveA31' tests/uat/harness.test.ts` returns >=3.
|
||
- `grep -c 'secret-do-not-log-123' tests/uat/lib/harness-page-driver.ts` returns exactly 1.
|
||
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with stdout containing `UAT harness: 32/32 assertions passed`.
|
||
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (Tier-1 inventory stays at 12).
|
||
</acceptance_criteria>
|
||
<done>
|
||
UAT harness runs 32/32 GREEN with A31 verifying the §10 #8 PARTIAL
|
||
contract: typing into a password input produces zero events whose
|
||
value contains the sentinel and zero events targeting the password
|
||
selector. The existing src/content/index.ts:82 filter is the
|
||
verified mechanism; full masking remains Out of Scope v1 per D-P3-02.
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<threat_model>
|
||
## Trust Boundaries
|
||
|
||
| Boundary | Description |
|
||
|----------|-------------|
|
||
| Puppeteer host ↔ page realm | Test harness drives via page.evaluate; production filter at src/content/index.ts:82 runs inside the page content script |
|
||
| Page realm ↔ content script | Synthetic input event dispatched on #probe-password reaches the production listener at src/content/index.ts:78 |
|
||
| Filter ↔ event log | Negative-assertion contract: filter at line 82 prevents value from reaching userEvents[] which is what assembles logs/events.json |
|
||
| dist-test/ ↔ dist/ | Two-bundle separation: Plan 03-03 adds NO test-only symbols; production bundle invariant unchanged |
|
||
|
||
## STRIDE Threat Register
|
||
|
||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||
|-----------|----------|-----------|-------------|-----------------|
|
||
| T-03-03-01 | Information Disclosure | Sentinel value lands in event log despite filter | mitigate | A31 IS the negative-assertion mitigation — RED A31 means the filter regressed; the test enforces the invariant. Sentinel is not a real secret (RESEARCH §"Security Domain"); if it leaked, it would be visible in events.json which is logged 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 | mitigate | A31 rides the production input listener; no `__MOKOSH_UAT__`-gated symbols. FORBIDDEN_HOOK_STRINGS unchanged at 12 entries. |
|
||
| T-03-03-03 | Tampering | A31 SAVE produces a zip in the per-run downloadsDir that contains the sentinel only IF the filter regressed | 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 | 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. |
|
||
|
||
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.
|
||
</threat_model>
|
||
|
||
<verification>
|
||
- `npx tsc --noEmit` exits 0.
|
||
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with 32/32 GREEN.
|
||
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (12 FORBIDDEN_HOOK_STRINGS × 0 hits each).
|
||
- A31 diagnostic line shows `sentinel-containing count=0, password-targeting count=0`.
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
- A31 GREEN with 3 merged checks (SAVE ack + 2 negative-assertions).
|
||
- The existing src/content/index.ts:82 password filter is verified to
|
||
fire on synthetic input events into the probe-page password element.
|
||
- FORBIDDEN_HOOK_STRINGS unchanged at 12 entries.
|
||
- vitest baseline preserved (171/171 GREEN).
|
||
- Plan 03-05 VERIFICATION.md will mark §10 #8 as PARTIAL with explicit
|
||
charter citation (D-P3-02) and reference A31 GREEN as the existing-
|
||
minimum evidence.
|
||
</success_criteria>
|
||
|
||
<output>
|
||
After completion, create
|
||
`.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.md`
|
||
documenting:
|
||
- Negative-assertion contract verified (2 orthogonal absence checks)
|
||
- D-P3-02 charter alignment (existing-minimum verification; not scope expansion)
|
||
- UAT 31 → 32 GREEN; Tier-1 inventory unchanged at 12
|
||
- Plan 03-04 wave dependency: SAME 3 harness files; SEQUENTIAL within Wave 2 per RESEARCH Pitfall 6
|
||
</output>
|