Skip to content

Commit 72cfd61

Browse files
committed
fix: bound adaptive policy block evidence
1 parent 6445ee1 commit 72cfd61

3 files changed

Lines changed: 161 additions & 20 deletions

File tree

packages/runtime-core/src/browser-adaptive-exploration.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ export interface BrowserAdaptiveTransition {
8686
loadingAfter: number
8787
oracleFingerprints: string[]
8888
networkFailures?: BrowserAdaptiveNetworkFailure[]
89+
networkFailureSummary?: {
90+
total: number
91+
retained: number
92+
policyBlocks: number
93+
oracleFindings: number
94+
truncated: boolean
95+
}
8996
accessibilityFindingFingerprints?: string[]
9097
}
9198
status: "ok" | "revisited" | "rejected" | "error" | "cancelled"

packages/runtime-playground/src/browser-adaptive-explorer.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export async function exploreAdaptiveBrowserStateMachine({
189189
loadingAfter: stabilized.loading,
190190
oracleFingerprints: fingerprints,
191191
...(oracleEvidence.networkFailures.length > 0 ? { networkFailures: oracleEvidence.networkFailures } : {}),
192+
...(oracleEvidence.networkFailureSummary ? { networkFailureSummary: oracleEvidence.networkFailureSummary } : {}),
192193
...(accessibilityFingerprints.length > 0 ? { accessibilityFindingFingerprints: accessibilityFingerprints } : {}),
193194
},
194195
status: actionError ? "error" : scopeRejection ? "rejected" : newState ? "ok" : "revisited",
@@ -569,36 +570,67 @@ function consoleErrorRecords(records: object[]): Record<string, unknown>[] {
569570
return records.map((record) => record as Record<string, unknown>).filter((record) => record.type === "error" || record.level === "error").slice(0, 100)
570571
}
571572

572-
function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], networkRecords: object[], pageErrors: string[], contract: BrowserAdaptiveExplorationContract, networkPolicy?: BrowserPreviewNetworkPolicy): { fingerprints: string[]; networkFailures: BrowserAdaptiveNetworkFailure[]; errorCount: number } {
573+
function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], networkRecords: object[], pageErrors: string[], contract: BrowserAdaptiveExplorationContract, networkPolicy?: BrowserPreviewNetworkPolicy): { fingerprints: string[]; networkFailures: BrowserAdaptiveNetworkFailure[]; networkFailureSummary?: BrowserAdaptiveTransition["observations"]["networkFailureSummary"]; errorCount: number } {
573574
const failures = networkRecords.map((record) => record as Record<string, unknown>).filter((record) => record.type === "requestfailed")
574-
const networkFailures = failures.map((record): BrowserAdaptiveNetworkFailure => {
575+
const classifiedFailures = failures.map((record): BrowserAdaptiveNetworkFailure & { expectedBlock: boolean } => {
575576
const url = typeof record.url === "string" ? record.url : ""
576577
const decision = networkPolicy ? browserPreviewNetworkDecision(url, networkPolicy) : { url, urlClassification: "invalid" as const, policyDecision: "unknown" as const, policyReason: "network-policy-unavailable" }
577578
const failure = networkFailureMessage(record)
578579
const expectedBlock = decision.policyDecision === "blocked" && /ERR_BLOCKED_BY_CLIENT|blockedbyclient/i.test(failure)
579-
return { ...decision, ...(failure ? { failure } : {}), oracleFinding: !expectedBlock || contract.oraclePolicy.policyBlocks === "finding" }
580+
return { ...decision, ...(failure ? { failure } : {}), oracleFinding: !expectedBlock || contract.oraclePolicy.policyBlocks === "finding", expectedBlock }
580581
})
581-
const expectedBlockUrls = networkFailures.filter((failure) => !failure.oracleFinding).map((failure) => failure.url)
582-
const findingConsoleRecords = consoleRecords.filter((record) => {
583-
if (!/ERR_BLOCKED_BY_CLIENT/i.test(recordMessage(record))) return true
584-
const location = objectRecord(record.location)
585-
const match = typeof location.url === "string" && location.url
586-
? expectedBlockUrls.indexOf(location.url)
587-
: expectedBlockUrls.length > 0 ? 0 : -1
588-
if (match < 0) return true
589-
expectedBlockUrls.splice(match, 1)
590-
return false
582+
const unmatchedFailures = new Set(classifiedFailures.map((_failure, index) => index))
583+
const consoleMessages = consoleRecords.flatMap((record) => {
584+
const message = recordMessage(record)
585+
const locationUrl = objectRecord(record.location).url
586+
const match = classifiedFailures.findIndex((failure, index) => unmatchedFailures.has(index)
587+
&& (typeof locationUrl === "string" && locationUrl ? failure.url === locationUrl : failureTokenMatches(message, failure.failure)))
588+
if (match < 0) return [message]
589+
unmatchedFailures.delete(match)
590+
const failure = classifiedFailures[match]!
591+
return failure.expectedBlock && contract.oraclePolicy.policyBlocks === "evidence" ? [] : [message]
591592
})
592-
const consoleUrls = new Set(findingConsoleRecords.map((record) => objectRecord(record.location).url).filter((url): url is string => typeof url === "string"))
593-
const networkMessages = networkFailures.filter((failure) => failure.oracleFinding && !consoleUrls.has(failure.url)).map((failure) => stableJson({ type: "requestfailed", url: failure.url, failure: failure.failure, urlClassification: failure.urlClassification, policyDecision: failure.policyDecision, policyReason: failure.policyReason }))
594-
const messages = [...findingConsoleRecords.map(recordMessage), ...pageErrors, ...networkMessages]
593+
const networkMessages = classifiedFailures.filter((failure, index) => unmatchedFailures.has(index) && failure.oracleFinding).map(networkFailureOracleMessage)
594+
const messages = [...consoleMessages, ...pageErrors, ...networkMessages]
595+
const networkFailureSummary = classifiedFailures.length > 0 ? boundedNetworkFailureEvidence(classifiedFailures, contract) : undefined
595596
return {
596597
fingerprints: [...new Set(messages.map((message) => browserAdaptiveDigest("oracle", message)))].sort(),
597-
networkFailures,
598-
errorCount: findingConsoleRecords.length + pageErrors.length + networkMessages.length,
598+
networkFailures: networkFailureSummary?.failures ?? [],
599+
networkFailureSummary: networkFailureSummary?.summary,
600+
errorCount: consoleMessages.length + pageErrors.length + networkMessages.length,
599601
}
600602
}
601603

604+
function boundedNetworkFailureEvidence(failures: Array<BrowserAdaptiveNetworkFailure & { expectedBlock: boolean }>, contract: BrowserAdaptiveExplorationContract): { failures: BrowserAdaptiveNetworkFailure[]; summary: NonNullable<BrowserAdaptiveTransition["observations"]["networkFailureSummary"]> } {
605+
const ordered = failures.map(({ expectedBlock: _expectedBlock, ...failure }) => failure).sort((left, right) => stableJson(left).localeCompare(stableJson(right)))
606+
const maximum = Math.min(contract.budgets.maxErrors, contract.descriptorLimits.maxDiagnostics)
607+
const byteBudget = Math.floor(contract.budgets.maxArtifactBytes / 8)
608+
const retained: BrowserAdaptiveNetworkFailure[] = []
609+
for (const failure of ordered.slice(0, maximum)) {
610+
if (Buffer.byteLength(stableJson([...retained, failure])) > byteBudget) break
611+
retained.push(failure)
612+
}
613+
return {
614+
failures: retained,
615+
summary: {
616+
total: failures.length,
617+
retained: retained.length,
618+
policyBlocks: failures.filter((failure) => failure.expectedBlock).length,
619+
oracleFindings: failures.filter((failure) => failure.oracleFinding).length,
620+
truncated: retained.length < failures.length,
621+
},
622+
}
623+
}
624+
625+
function failureTokenMatches(message: string, failure: string | undefined): boolean {
626+
const token = failure?.match(/ERR_[A-Z0-9_]+/i)?.[0]
627+
return Boolean(token && message.toUpperCase().includes(token.toUpperCase()))
628+
}
629+
630+
function networkFailureOracleMessage(failure: BrowserAdaptiveNetworkFailure): string {
631+
return stableJson({ type: "requestfailed", url: failure.url, failure: failure.failure, urlClassification: failure.urlClassification, policyDecision: failure.policyDecision, policyReason: failure.policyReason })
632+
}
633+
602634
function networkFailureMessage(record: Record<string, unknown>): string {
603635
const failure = objectRecord(record.failure)
604636
return typeof failure.errorText === "string" ? failure.errorText : typeof record.failure === "string" ? record.failure : ""

tests/browser-adaptive-exploration.test.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { chromium, type Page } from "playwright"
55

66
import {
77
BROWSER_ADAPTIVE_EXPLORATION_SCHEMA,
8+
browserAdaptiveDigest,
89
browserAdaptiveExplorationContract,
910
planBrowserAdaptiveStateActions,
1011
} from "../packages/runtime-core/src/browser-adaptive-exploration.js"
@@ -506,6 +507,28 @@ test("callers can opt policy blocks back into deterministic findings and replay"
506507
assert.equal(finding.replay.expectedFingerprint, finding.fingerprint)
507508
})
508509

510+
test("URL-less policy-block console records correlate before promotion and preserve canonical fingerprints", async () => {
511+
const evidenceOnly = await runNetworkOracleFixture("block", "blocked", {}, true)
512+
assert.equal(evidenceOnly.result.findings.length, 0)
513+
assert.deepEqual(evidenceOnly.result.transitions[0]?.observations.oracleFingerprints, [])
514+
assert.equal(evidenceOnly.result.transitions[0]?.observations.networkFailureSummary?.policyBlocks, 1)
515+
516+
const input = { oraclePolicy: { policyBlocks: "finding" } }
517+
const attributed = await runNetworkOracleFixture("block", "blocked", input)
518+
const urlLess = await runNetworkOracleFixture("block", "blocked", input, true)
519+
const message = urlLess.result.transitions[0]?.observations.consoleErrors[0]
520+
assert(message)
521+
assert.equal(urlLess.result.transitions[0]?.observations.oracleFingerprints.length, 1)
522+
assert.equal(urlLess.result.findings[0]?.fingerprint, browserAdaptiveDigest("oracle", message))
523+
assert.equal(urlLess.result.findings[0]?.fingerprint, attributed.result.findings[0]?.fingerprint, "removing console location must not change the historical console fingerprint")
524+
525+
const mixed = await runNetworkOracleFixture("block", "mixed", {}, true)
526+
assert.equal(mixed.result.findings.length, 1)
527+
assert.equal(mixed.result.transitions[0]?.observations.oracleFingerprints.length, 1)
528+
assert.equal(mixed.result.transitions[0]?.observations.networkFailureSummary?.policyBlocks, 1)
529+
assert.equal(mixed.result.transitions[0]?.observations.networkFailureSummary?.oracleFindings, 1)
530+
})
531+
509532
test("allow and record policies do not explain unexpected same-origin request failures", async () => {
510533
for (const mode of ["allow", "record"] as const) {
511534
const run = await runNetworkOracleFixture(mode, "unexpected")
@@ -536,6 +559,31 @@ test("page exceptions remain findings alongside policy-aware network classificat
536559
assert.deepEqual(run.result.findings[0]?.replay.actions, run.result.findings[0]?.minimizedPath)
537560
})
538561

562+
test("policy-block floods retain bounded deterministic evidence without stopping later findings", async () => {
563+
const [first, second] = await runPolicyBlockFloodFixture()
564+
for (const result of [first, second]) {
565+
const flood = result.transitions.find((transition) => transition.observations.networkFailureSummary?.total === 80)
566+
assert(flood)
567+
assert.deepEqual(flood.observations.networkFailureSummary, { total: 80, retained: 2, policyBlocks: 80, oracleFindings: 0, truncated: true })
568+
assert.equal(flood.observations.networkFailures?.length, 2)
569+
assert.deepEqual(flood.observations.oracleFingerprints, [])
570+
assert.equal(result.status, "findings")
571+
assert.equal(result.summary.errors, 1, "policy blocks must not consume the product error budget")
572+
assert.notEqual(result.summary.budgetExhausted, "maxArtifactBytes")
573+
assert.equal(result.findings.length, 1)
574+
assert.equal(result.findings[0]?.originalPath.length, 2)
575+
assert.deepEqual(result.findings[0]?.minimizedPath, result.findings[0]?.originalPath)
576+
assert.deepEqual(result.findings[0]?.replay.actions, result.findings[0]?.minimizedPath)
577+
}
578+
const stable = (result: typeof first) => ({
579+
status: result.status,
580+
summary: result.summary,
581+
transitions: result.transitions.map((transition) => ({ action: transition.action.id, observations: transition.observations, status: transition.status })),
582+
findings: result.findings,
583+
})
584+
assert.deepEqual(stable(second), stable(first))
585+
})
586+
539587
test("loops, budgets, cancellation, frames, and partial evidence remain bounded", async () => {
540588
const loopFixture = `<!doctype html><style>button,input,a,iframe { display:block;width:100px;height:30px }</style><button id="toggle">Toggle</button><form id="first"><input name="query"></form><form id="second"><input name="query"></form><a id="external" href="https://example.test/outside">External</a><iframe id="same" srcdoc="<style>button{width:100px;height:30px}</style><button id='framed'>Framed</button>"></iframe><script>toggle.onclick=()=>document.body.classList.toggle('on')</script>`
541589
const bounded = await runFixture(loopFixture, {
@@ -651,7 +699,7 @@ async function runRoutedFixture(topology: ReturnType<typeof browserPreviewTopolo
651699
}
652700
}
653701

654-
async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behavior: "blocked" | "unexpected" | "mixed" | "exception", input: Record<string, unknown> = {}) {
702+
async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behavior: "blocked" | "unexpected" | "mixed" | "exception", input: Record<string, unknown> = {}, urlLessConsole = false) {
655703
const server = createServer((request, response) => {
656704
const path = new URL(request.url ?? "/", "http://preview.invalid").pathname
657705
if (path === "/failed.png") {
@@ -677,7 +725,8 @@ async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behav
677725
const consoleMessages: Record<string, unknown>[] = []
678726
const errors: Record<string, unknown>[] = []
679727
const network: Record<string, unknown>[] = []
680-
attachBrowserCaptureListeners({ captureConsole: true, captureErrors: true, captureNetwork: true, captureWebSocket: false, consoleMessages, errors, network, page })
728+
attachBrowserCaptureListeners({ captureConsole: !urlLessConsole, captureErrors: true, captureNetwork: true, captureWebSocket: false, consoleMessages, errors, network, page })
729+
if (urlLessConsole) page.on("console", (message) => consoleMessages.push({ type: message.type(), text: message.text() }))
681730
await page.addInitScript("globalThis.__name = value => value")
682731
await page.goto(startUrl, { waitUntil: "load" })
683732
const contract = browserAdaptiveExplorationContract({
@@ -699,6 +748,59 @@ async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behav
699748
}
700749
}
701750

751+
async function runPolicyBlockFloodFixture() {
752+
const server = createServer((_request, response) => {
753+
response.setHeader("content-type", "text/html")
754+
response.end(`<!doctype html><style>button{display:block;width:180px;height:30px}</style><button id="trigger">Load assets</button><script>
755+
document.querySelector('#trigger').addEventListener('click', () => {
756+
for (let index = 0; index < 80; index += 1) {
757+
const image = document.createElement('img');
758+
image.src = 'http://assets.example.invalid/tile-' + String(index).padStart(3, '0') + '.png';
759+
document.body.append(image);
760+
}
761+
const fail = document.createElement('button');
762+
fail.id = 'fail';
763+
fail.textContent = 'Reveal defect';
764+
fail.addEventListener('click', () => setTimeout(() => { throw new Error('post-flood defect'); }, 0));
765+
document.body.append(fail);
766+
});
767+
</script>`)
768+
})
769+
const startUrl = await listenLocalHttpServer(server)
770+
const topology = browserPreviewTopology(["network-policy=block"], undefined, startUrl)
771+
const run = async () => {
772+
const browser = await chromium.launch({ headless: true })
773+
const context = await browser.newContext()
774+
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, startUrl)
775+
const page = await context.newPage()
776+
const consoleMessages: Record<string, unknown>[] = []
777+
const errors: Record<string, unknown>[] = []
778+
const network: Record<string, unknown>[] = []
779+
attachBrowserCaptureListeners({ captureConsole: true, captureErrors: true, captureNetwork: true, captureWebSocket: false, consoleMessages, errors, network, page })
780+
await page.addInitScript("globalThis.__name = value => value")
781+
await page.goto(startUrl, { waitUntil: "load" })
782+
const contract = browserAdaptiveExplorationContract({
783+
seed: "policy-block-flood",
784+
startUrl,
785+
failOnFinding: false,
786+
actionFamilies: ["click"],
787+
budgets: { maxActions: 20, maxStates: 6, maxTransitions: 10, maxDurationMs: 20_000, maxArtifactBytes: 40_000, maxErrors: 2 },
788+
descriptorLimits: { maxPerState: 10, maxDiagnostics: 3, maxTextLength: 500 },
789+
stabilization: { pollIntervalMs: 25, quietWindowMs: 100, maxWaitMs: 1_500, maxMutationRecords: 100 },
790+
})
791+
try {
792+
return await exploreAdaptiveBrowserStateMachine({ page, baseUrl: startUrl, contract, observations: { consoleMessages, errors, network }, navigationScope: topology.navigationScope, networkPolicy: topology.networkPolicy })
793+
} finally {
794+
await browser.close()
795+
}
796+
}
797+
try {
798+
return [await run(), await run()] as const
799+
} finally {
800+
await closeHttpServer(server)
801+
}
802+
}
803+
702804
function stableAdaptiveEvidence(result: Awaited<ReturnType<typeof runRoutedFixture>>) {
703805
return {
704806
states: result.states.map((state) => ({ digest: state.digest, url: state.url, descriptors: state.descriptors.map((descriptor) => descriptor.id) })),

0 commit comments

Comments
 (0)