diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 3bc6475e49..44e24501b3 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -118,8 +118,9 @@ rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \ ``` The omitted observation window and capture options use conservative defaults. -Set an option only when the investigation needs that additional content; use -the `limits` object to override bounded collection budgets. +Set an option only when the investigation needs that additional content. +Collection budgets are applied internally and cannot be changed per call; +truncated sections and dropped counts make incomplete captures explicit. Call `list_browser_targets` first because target IDs are browser-instance-specific. REA rechecks the selected target's current type and origin immediately before attaching. @@ -146,7 +147,7 @@ prove static module ownership. ## Completeness and limits -The default observation window is 500 ms and the maximum is 10 seconds. Frames (200), DOM nodes (2,000), accessibility nodes (2,000), scripts (200), resources (2,000), workers (500), and each storage key/name inventory (1,000) have conservative defaults and caller-visible hard bounds. Script source bytes, network events, console events, and WebSocket events are bounded separately. The CLI exposes the same limits as kebab-case options such as `--max-frames` and `--max-storage-keys`. +The default observation window is 500 ms and the maximum is 10 seconds. Frames (200), DOM nodes (2,000), accessibility nodes (2,000), scripts (200), resources (2,000), workers (500), and each storage key/name inventory (1,000) have internal collection budgets. Script source bytes, network events, console events, and WebSocket events are bounded separately. These budgets are fixed by REA rather than caller-configurable; results identify truncated sections and dropped counts. Every result distinguishes `complete_within_window`, `policy_filtered`, `attach_limited`, and `truncated` coverage. It names affected sections and reports sparse excluded-item and per-event-type dropped counts rather than implying exhaustive capture. Disallowed-origin entries are filtered before collection-limit accounting, so they cannot consume an approved origin's output budget. diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 0ebeb0a126..0009eea0b7 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -50,8 +50,8 @@ inventories authorized worker, service-worker, and shared-worker targets with bounded opener-target and parent-frame IDs. Worker discovery uses passive target metadata; REA does not attach to or execute code in those targets. -The default worker limit is 500 and the caller-visible maximum is 5,000. Like -every target, frame, script, and resource, a worker URL must resolve beneath an +The worker collection budget is 500 and is applied internally. Like every +target, frame, script, and resource, a worker URL must resolve beneath an approved canonical root before it is retained. Relationship IDs improve attribution but do not prove which static module started a worker or that its work completed. diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md index 811d37e107..4288ac842b 100644 --- a/docs/javascript-runtime-observation.md +++ b/docs/javascript-runtime-observation.md @@ -79,10 +79,10 @@ targets belong to the same Electron application. ## Bounds and determinism -Every observation commits the window and exact limits for events, scripts, -execution contexts, per-location bytes, and total retained metadata bytes. -Limit hits increment dropped-event counts and set `capture.truncated`; they -never become completeness claims. Scripts are authorized after capture, +Every observation records the window and effective internal collection bounds +for events, scripts, execution contexts, per-location bytes, and total retained +metadata bytes. These bounds are not caller-configurable. Limit hits increment +dropped-event counts and set `capture.truncated`; they never become completeness claims. Scripts are authorized after capture, deduplicated by stable metadata, and canonically sorted. Wall-clock timestamps and protocol script IDs are excluded from the durable result, so identical inputs and captured metadata produce the same Evidence ID. diff --git a/src/cliBrowserCommands.ts b/src/cliBrowserCommands.ts index 82457ec0cc..d3cc5fa48f 100644 --- a/src/cliBrowserCommands.ts +++ b/src/cliBrowserCommands.ts @@ -95,31 +95,6 @@ const registerPageInspection = ( include_script_sources: options.includeScriptSources, include_storage_keys: options.includeStorageKeys, include_storage_fingerprints: options.includeStorageFingerprints, - limits: { - max_frames: options.maxFrames, - max_dom_nodes: options.maxDomNodes, - max_ax_nodes: options.maxAxNodes, - max_ax_text_field_bytes: options.maxAxTextFieldBytes, - max_total_ax_text_bytes: options.maxTotalAxTextBytes, - max_scripts: options.maxScripts, - max_resources: options.maxResources, - max_workers: options.maxWorkers, - max_storage_keys: options.maxStorageKeys, - max_script_source_bytes: options.maxScriptSourceBytes, - max_total_script_source_bytes: options.maxTotalScriptSourceBytes, - max_network_events: options.maxNetworkEvents, - max_console_events: options.maxConsoleEvents, - max_console_text_field_bytes: options.maxConsoleTextFieldBytes, - max_total_console_text_bytes: options.maxTotalConsoleTextBytes, - max_json_body_bytes: options.maxJsonBodyBytes, - max_total_json_body_bytes: options.maxTotalJsonBodyBytes, - max_json_shape_nodes: options.maxJsonShapeNodes, - max_json_shape_depth: options.maxJsonShapeDepth, - max_websocket_events: options.maxWebsocketEvents, - max_websocket_shape_bytes: options.maxWebsocketShapeBytes, - max_total_websocket_shape_bytes: - options.maxTotalWebsocketShapeBytes, - }, }); if (!parsed.success) return browserCliError(new AnalysisInputError("inspect_web_page")); diff --git a/src/cliElectronCommands.ts b/src/cliElectronCommands.ts index 9918999b12..b8fcea110e 100644 --- a/src/cliElectronCommands.ts +++ b/src/cliElectronCommands.ts @@ -178,15 +178,6 @@ const registerElectronPageInspection = ( target_id: args.targetId, observation_ms: options.observationMs, include_script_sources: options.includeScriptSources, - limits: { - max_frames: options.maxFrames, - max_dom_nodes: options.maxDomNodes, - max_scripts: options.maxScripts, - max_resources: options.maxResources, - max_workers: options.maxWorkers, - max_script_source_bytes: options.maxScriptSourceBytes, - max_total_script_source_bytes: options.maxTotalScriptSourceBytes, - }, }); if (!parsed.success) return inputError("inspect_electron_page"); const result = await inspectElectronPage( diff --git a/src/cliJavaScriptRuntimeCommands.ts b/src/cliJavaScriptRuntimeCommands.ts index c1f52cc975..8854dfede6 100644 --- a/src/cliJavaScriptRuntimeCommands.ts +++ b/src/cliJavaScriptRuntimeCommands.ts @@ -52,41 +52,6 @@ const observeOptionsSchema = z.object({ .max(10_000) .default(100) .describe("Bounded observation window in milliseconds"), - maxEvents: z - .number() - .int() - .min(1) - .max(50_000) - .default(10_000) - .describe("Maximum retained protocol events"), - maxScripts: z - .number() - .int() - .min(1) - .max(10_000) - .default(2_000) - .describe("Maximum retained scripts"), - maxExecutionContexts: z - .number() - .int() - .min(1) - .max(5_000) - .default(1_000) - .describe("Maximum retained execution contexts"), - maxLocationBytes: z - .number() - .int() - .min(64) - .max(65_536) - .default(16_384) - .describe("Maximum bytes in one protocol location"), - maxTotalMetadataBytes: z - .number() - .int() - .min(1_024) - .max(32 * 1_024 * 1_024) - .default(4 * 1_024 * 1_024) - .describe("Maximum retained metadata bytes"), }); /** Register CLI equivalents of passive V8 Inspector tools. */ @@ -148,13 +113,6 @@ export const registerJavaScriptRuntimeObservationCommands = ( target_id: args.targetId, runtime_kind: options.runtimeKind, observation_ms: options.observationMs, - limits: { - max_events: options.maxEvents, - max_scripts: options.maxScripts, - max_execution_contexts: options.maxExecutionContexts, - max_location_bytes: options.maxLocationBytes, - max_total_metadata_bytes: options.maxTotalMetadataBytes, - }, }); if (!parsed.success) return inputError("observe_javascript_runtime"); const result = await observeJavaScriptRuntime( diff --git a/src/cliObservationOptions.ts b/src/cliObservationOptions.ts index 3650d3c5a2..b5fd8b0dc1 100644 --- a/src/cliObservationOptions.ts +++ b/src/cliObservationOptions.ts @@ -15,13 +15,6 @@ export const boundedCount = ( .default(fallback) .describe(`Maximum ${subject}`); -/** Bound a CLI observation byte budget. */ -export const boundedBytes = ( - subject: string, - maximum: number, - fallback: number, -) => boundedCount(`${subject} in bytes`, maximum, fallback); - /** Shared passive browser origin options. */ export const browserScopeOptions = { allowedOrigins: z @@ -68,68 +61,6 @@ export const browserPageInspectionOptions = z.object({ .boolean() .default(false) .describe("Include content-derived storage fingerprints"), - maxFrames: boundedCount("page frames", 1_000, 200), - maxDomNodes: boundedCount("DOM nodes", 10_000, 2_000), - maxAxNodes: boundedCount("accessibility nodes", 10_000, 2_000), - maxAxTextFieldBytes: boundedBytes( - "one accessibility text field", - 16 * 1_024, - 1_024, - ), - maxTotalAxTextBytes: boundedBytes( - "total accessibility text", - 1_024 * 1_024, - 64 * 1_024, - ), - maxScripts: boundedCount("scripts", 1_000, 200), - maxResources: boundedCount("resources", 10_000, 2_000), - maxWorkers: boundedCount("workers", 5_000, 500), - maxStorageKeys: boundedCount("storage keys", 10_000, 1_000), - maxScriptSourceBytes: boundedBytes( - "one script source", - 4 * 1_024 * 1_024, - 1_024 * 1_024, - ), - maxTotalScriptSourceBytes: boundedBytes( - "total script source", - 16 * 1_024 * 1_024, - 4 * 1_024 * 1_024, - ), - maxNetworkEvents: boundedCount("network events", 10_000, 1_000), - maxConsoleEvents: boundedCount("console events", 2_000, 200), - maxConsoleTextFieldBytes: boundedBytes( - "one console text field", - 16 * 1_024, - 1_024, - ), - maxTotalConsoleTextBytes: boundedBytes( - "total console text", - 1_024 * 1_024, - 64 * 1_024, - ), - maxJsonBodyBytes: boundedBytes( - "one JSON response body", - 4 * 1_024 * 1_024, - 1_024 * 1_024, - ), - maxTotalJsonBodyBytes: boundedBytes( - "total JSON response bodies", - 16 * 1_024 * 1_024, - 4 * 1_024 * 1_024, - ), - maxJsonShapeNodes: boundedCount("JSON shape nodes", 100_000, 5_000), - maxJsonShapeDepth: boundedCount("JSON shape depth", 100, 20), - maxWebsocketEvents: boundedCount("WebSocket events", 5_000, 500), - maxWebsocketShapeBytes: boundedBytes( - "one WebSocket shape", - 1_024 * 1_024, - 64 * 1_024, - ), - maxTotalWebsocketShapeBytes: boundedBytes( - "total WebSocket shapes", - 16 * 1_024 * 1_024, - 1_024 * 1_024, - ), }); /** Shared passive Electron root options. */ @@ -152,21 +83,6 @@ export const electronPageInspectionOptions = z.object({ .boolean() .default(false) .describe("Include bounded JavaScript source text"), - maxFrames: boundedCount("page frames", 1_000, 200), - maxDomNodes: boundedCount("DOM nodes", 10_000, 2_000), - maxScripts: boundedCount("scripts", 2_000, 500), - maxResources: boundedCount("resources", 10_000, 2_000), - maxWorkers: boundedCount("workers", 5_000, 500), - maxScriptSourceBytes: boundedBytes( - "one captured script source", - 4 * 1_024 * 1_024, - 1_024 * 1_024, - ), - maxTotalScriptSourceBytes: boundedBytes( - "total captured script source", - 16 * 1_024 * 1_024, - 4 * 1_024 * 1_024, - ), }); export const javascriptApplicationOptions = z.object({ diff --git a/src/contracts/browserToolContracts.ts b/src/contracts/browserToolContracts.ts index e9b896c4fc..eb1a15b3b0 100644 --- a/src/contracts/browserToolContracts.ts +++ b/src/contracts/browserToolContracts.ts @@ -1,7 +1,7 @@ import type { ToolContract } from "./toolContracts.js"; import { browserTargetListSchema, - inspectWebPageInputSchema, + inspectWebPageToolInputSchema, listBrowserTargetsInputSchema, webPageInspectionSchema, } from "../domain/browserObservation.js"; @@ -140,7 +140,7 @@ export const BROWSER_TOOL_CONTRACTS = [ description: "Passively inspect one approved page target through CDP without evaluating JavaScript, navigating, clicking, closing, or mutating the page. Returns bounded DOM structure, accessibility, scripts, resources, attach-window network and console metadata, workers, and redacted storage inventory as Evidence.", kind: "browser-provider", - inputSchema: inspectWebPageInputSchema, + inputSchema: inspectWebPageToolInputSchema, outputSchema: inspectionOutputSchema, examples: [ { diff --git a/src/contracts/electronToolContracts.ts b/src/contracts/electronToolContracts.ts index dd8bd0ae3b..7f89e8d34e 100644 --- a/src/contracts/electronToolContracts.ts +++ b/src/contracts/electronToolContracts.ts @@ -4,7 +4,7 @@ import { evidenceResultOf } from "./toolOutputSchemas.js"; import { electronPageInspectionSchema, electronTargetListSchema, - inspectElectronPageInputSchema, + inspectElectronPageToolInputSchema, listElectronTargetsInputSchema, } from "../domain/electronObservation.js"; import { @@ -74,7 +74,7 @@ export const ELECTRON_TOOL_CONTRACTS = [ description: "Passively inspect one approved Electron file page through CDP. Returns root-confined frames, DOM structure, resource paths, and scripts without evaluating renderer JavaScript or invoking Electron APIs; script contents require separate approval.", kind: "electron-provider", - inputSchema: inspectElectronPageInputSchema, + inputSchema: inspectElectronPageToolInputSchema, outputSchema: inspectionOutputSchema, examples: [ { diff --git a/src/contracts/javascriptRuntimeObservationToolContracts.ts b/src/contracts/javascriptRuntimeObservationToolContracts.ts index d14c3203ea..e7fbb5bf2d 100644 --- a/src/contracts/javascriptRuntimeObservationToolContracts.ts +++ b/src/contracts/javascriptRuntimeObservationToolContracts.ts @@ -5,7 +5,7 @@ import { javascriptRuntimeObservationSchema, javascriptRuntimeTargetListSchema, listJavaScriptRuntimeTargetsInputSchema, - observeJavaScriptRuntimeInputSchema, + observeJavaScriptRuntimeToolInputSchema, } from "../domain/javascriptRuntimeObservation.js"; const root = "/opt/example-app"; @@ -38,7 +38,7 @@ export const JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS = [ description: "Attach passively to one exact approved Node/Electron V8 Inspector target and capture bounded Debugger.scriptParsed plus Runtime execution-context events. REA sends only Runtime.enable and Debugger.enable: it never evaluates, pauses, resumes, reads source, or instruments the target. require/import edges, EventEmitter activity, and Electron IPC remain explicit unknowns; reconcile the returned Evidence with static Application Graph Evidence using reconcile_javascript_runtime.", kind: "runtime-provider", - inputSchema: observeJavaScriptRuntimeInputSchema, + inputSchema: observeJavaScriptRuntimeToolInputSchema, outputSchema: evidenceResultOf(javascriptRuntimeObservationSchema), examples: [ { diff --git a/src/domain/browserObservation.test.ts b/src/domain/browserObservation.test.ts index 2684fbddf9..194268068d 100644 --- a/src/domain/browserObservation.test.ts +++ b/src/domain/browserObservation.test.ts @@ -101,6 +101,14 @@ describe("browser observation contracts", () => { max_total_websocket_shape_bytes: 1_048_576, }, }); + expect( + inspectWebPageInputSchema.safeParse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: ["https://app.example.test"], + target_id: "page-1", + limits: {}, + }).success, + ).toBe(false); }); }); diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index fd38a8e2e9..7e985c86c8 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -102,82 +102,8 @@ export const listBrowserTargetsInputSchema = z.object({ ...browserInput, }); -const browserInspectionLimitsSchema = z.object({ - max_frames: z.number().int().min(1).max(1_000).default(200), - max_dom_nodes: z.number().int().min(1).max(10_000).default(2_000), - max_ax_nodes: z.number().int().min(1).max(10_000).default(2_000), - max_ax_text_field_bytes: z - .number() - .int() - .min(1) - .max(16 * 1_024) - .default(1_024), - max_total_ax_text_bytes: z - .number() - .int() - .min(1) - .max(1_024 * 1_024) - .default(64 * 1_024), - max_scripts: z.number().int().min(1).max(1_000).default(200), - max_resources: z.number().int().min(1).max(10_000).default(2_000), - max_workers: z.number().int().min(1).max(5_000).default(500), - max_storage_keys: z.number().int().min(1).max(10_000).default(1_000), - max_script_source_bytes: z - .number() - .int() - .min(1) - .max(4 * 1_024 * 1_024) - .default(1_024 * 1_024), - max_total_script_source_bytes: z - .number() - .int() - .min(1) - .max(16 * 1_024 * 1_024) - .default(4 * 1_024 * 1_024), - max_network_events: z.number().int().min(1).max(10_000).default(1_000), - max_console_events: z.number().int().min(1).max(2_000).default(200), - max_console_text_field_bytes: z - .number() - .int() - .min(1) - .max(16 * 1_024) - .default(1_024), - max_total_console_text_bytes: z - .number() - .int() - .min(1) - .max(1_024 * 1_024) - .default(64 * 1_024), - max_json_body_bytes: z - .number() - .int() - .min(1) - .max(4 * 1_024 * 1_024) - .default(1_024 * 1_024), - max_total_json_body_bytes: z - .number() - .int() - .min(1) - .max(16 * 1_024 * 1_024) - .default(4 * 1_024 * 1_024), - max_json_shape_nodes: z.number().int().min(1).max(100_000).default(5_000), - max_json_shape_depth: z.number().int().min(1).max(100).default(20), - max_websocket_events: z.number().int().min(1).max(5_000).default(500), - max_websocket_shape_bytes: z - .number() - .int() - .min(1) - .max(1_024 * 1_024) - .default(64 * 1_024), - max_total_websocket_shape_bytes: z - .number() - .int() - .min(1) - .max(16 * 1_024 * 1_024) - .default(1_024 * 1_024), -}); - -const DEFAULT_BROWSER_INSPECTION_LIMITS = { +/** Collection bounds for passive browser capture, applied internally. */ +export const DEFAULT_BROWSER_INSPECTION_LIMITS = { max_frames: 200, max_dom_nodes: 2_000, max_ax_nodes: 2_000, @@ -200,7 +126,7 @@ const DEFAULT_BROWSER_INSPECTION_LIMITS = { max_websocket_events: 500, max_websocket_shape_bytes: 64 * 1_024, max_total_websocket_shape_bytes: 1_024 * 1_024, -} as const; +}; const inspectWebPageInputFacts = { ...browserInput, @@ -212,9 +138,6 @@ const inspectWebPageInputFacts = { include_websocket_shapes: z.boolean().default(false), include_storage_keys: z.boolean().default(false), include_storage_fingerprints: z.boolean().default(false), - limits: browserInspectionLimitsSchema.default( - DEFAULT_BROWSER_INSPECTION_LIMITS, - ), } as const; const inspectWebPageWithoutSourceSchema = z.strictObject({ @@ -241,61 +164,25 @@ const refineInspectWebPageInput = ( path: ["include_storage_fingerprints"], message: "Storage fingerprints require storage key capture", }); - if ( - input.limits.max_script_source_bytes > - input.limits.max_total_script_source_bytes - ) - context.addIssue({ - code: "custom", - path: ["limits", "max_script_source_bytes"], - message: "Per-script source limit cannot exceed the total source limit", - }); - if ( - input.limits.max_console_text_field_bytes > - input.limits.max_total_console_text_bytes - ) - context.addIssue({ - code: "custom", - path: ["limits", "max_console_text_field_bytes"], - message: - "Per-field console text limit cannot exceed the total text limit", - }); - if (input.limits.max_json_body_bytes > input.limits.max_total_json_body_bytes) - context.addIssue({ - code: "custom", - path: ["limits", "max_json_body_bytes"], - message: "Per-body JSON limit cannot exceed the total body limit", - }); - if ( - input.limits.max_websocket_shape_bytes > - input.limits.max_total_websocket_shape_bytes - ) - context.addIssue({ - code: "custom", - path: ["limits", "max_websocket_shape_bytes"], - message: "Per-frame WebSocket limit cannot exceed the total frame limit", - }); - if ( - input.limits.max_ax_text_field_bytes > input.limits.max_total_ax_text_bytes - ) - context.addIssue({ - code: "custom", - path: ["limits", "max_ax_text_field_bytes"], - message: - "Per-field accessibility text limit cannot exceed the total text limit", - }); }; -/** Source-capturing inspection input, reusable by static bundle analysis. */ +/** Caller schema for source-capturing inspection and bundle analysis. */ export const inspectWebPageWithSourceInputSchema = inspectWebPageWithSourceShapeSchema.superRefine(refineInspectWebPageInput); -/** Public input for one passive, bounded inspection of an existing page. */ -export const inspectWebPageInputSchema = z.union([ +/** Caller-visible schema for one passive inspection. */ +export const inspectWebPageToolInputSchema = z.union([ inspectWebPageWithoutSourceSchema.superRefine(refineInspectWebPageInput), inspectWebPageWithSourceInputSchema, ]); +/** Internal parsed input with REA's fixed collection bounds applied. */ +export const inspectWebPageInputSchema = + inspectWebPageToolInputSchema.transform((input) => ({ + ...input, + limits: DEFAULT_BROWSER_INSPECTION_LIMITS, + })); + export type ListBrowserTargetsInput = z.infer< typeof listBrowserTargetsInputSchema >; diff --git a/src/domain/electronObservation.ts b/src/domain/electronObservation.ts index 502e89cf73..7e867a1562 100644 --- a/src/domain/electronObservation.ts +++ b/src/domain/electronObservation.ts @@ -24,6 +24,17 @@ const approvedElectronInput = { allowed_file_roots: electronFileRootsSchema, }; +/** Collection bounds for passive Electron capture, applied internally. */ +export const DEFAULT_ELECTRON_INSPECTION_LIMITS = { + max_frames: 200, + max_dom_nodes: 2_000, + max_scripts: 500, + max_resources: 2_000, + max_workers: 500, + max_script_source_bytes: 1_024 * 1_024, + max_total_script_source_bytes: 4 * 1_024 * 1_024, +}; + /** Input for listing root-confined file:// page targets from Electron CDP. */ export const listElectronTargetsInputSchema = z.strictObject({ ...approvedElectronInput, @@ -37,35 +48,6 @@ const inspectElectronPageFacts = { ...approvedElectronInput, target_id: z.string().trim().min(1).max(256), observation_ms: z.number().int().min(0).max(10_000).default(100), - limits: z - .strictObject({ - max_frames: z.number().int().min(1).max(1_000).default(200), - max_dom_nodes: z.number().int().min(1).max(10_000).default(2_000), - max_scripts: z.number().int().min(1).max(2_000).default(500), - max_resources: z.number().int().min(1).max(10_000).default(2_000), - max_workers: z.number().int().min(1).max(5_000).default(500), - max_script_source_bytes: z - .number() - .int() - .min(1) - .max(4 * 1_024 * 1_024) - .default(1_024 * 1_024), - max_total_script_source_bytes: z - .number() - .int() - .min(1) - .max(16 * 1_024 * 1_024) - .default(4 * 1_024 * 1_024), - }) - .default({ - max_frames: 200, - max_dom_nodes: 2_000, - max_scripts: 500, - max_resources: 2_000, - max_workers: 500, - max_script_source_bytes: 1_024 * 1_024, - max_total_script_source_bytes: 4 * 1_024 * 1_024, - }), } as const; export const inspectElectronPageInputSchema = z .union([ @@ -78,17 +60,20 @@ export const inspectElectronPageInputSchema = z include_script_sources: z.literal(true), }), ]) - .superRefine((input, context) => { - if ( - input.limits.max_script_source_bytes > - input.limits.max_total_script_source_bytes - ) - context.addIssue({ - code: "custom", - path: ["limits", "max_script_source_bytes"], - message: "Per-script source limit cannot exceed the total source limit", - }); - }); + .transform((input) => ({ + ...input, + limits: DEFAULT_ELECTRON_INSPECTION_LIMITS, + })); +export const inspectElectronPageToolInputSchema = z.union([ + z.strictObject({ + ...inspectElectronPageFacts, + include_script_sources: z.literal(false).default(false), + }), + z.strictObject({ + ...inspectElectronPageFacts, + include_script_sources: z.literal(true), + }), +]); export type InspectElectronPageInput = z.infer< typeof inspectElectronPageInputSchema >; diff --git a/src/domain/javascriptRuntimeObservation.ts b/src/domain/javascriptRuntimeObservation.ts index 9f1d056d90..0e514ad449 100644 --- a/src/domain/javascriptRuntimeObservation.ts +++ b/src/domain/javascriptRuntimeObservation.ts @@ -72,35 +72,31 @@ const observedJavaScriptRuntimeKindSchema = javascriptRuntimeKindSchema.or( z.literal("unknown"), ); -export const javascriptRuntimeObservationLimitsSchema = z.strictObject({ - max_events: z.number().int().min(1).max(50_000).default(10_000), - max_scripts: z.number().int().min(1).max(10_000).default(2_000), - max_execution_contexts: z.number().int().min(1).max(5_000).default(1_000), - max_location_bytes: z.number().int().min(64).max(65_536).default(16_384), - max_total_metadata_bytes: z - .number() - .int() - .min(1_024) - .max(32 * 1_024 * 1_024) - .default(4 * 1_024 * 1_024), -}); +/** Collection bounds for passive V8 Inspector capture, applied internally. */ +export const DEFAULT_JAVASCRIPT_RUNTIME_OBSERVATION_LIMITS = { + max_events: 10_000, + max_scripts: 2_000, + max_execution_contexts: 1_000, + max_location_bytes: 16_384, + max_total_metadata_bytes: 4 * 1_024 * 1_024, +}; /** Input for one bounded, attach-only V8 Inspector observation. */ -export const observeJavaScriptRuntimeInputSchema = z +export const observeJavaScriptRuntimeToolInputSchema = z .strictObject({ ...runtimeScope, target_id: z.string().trim().min(1).max(256), runtime_kind: javascriptRuntimeKindSchema.optional(), observation_ms: z.number().int().min(0).max(10_000).default(100), - limits: javascriptRuntimeObservationLimitsSchema.default({ - max_events: 10_000, - max_scripts: 2_000, - max_execution_contexts: 1_000, - max_location_bytes: 16_384, - max_total_metadata_bytes: 4 * 1_024 * 1_024, - }), }) .superRefine(requireRuntimeScope); + +/** Internal parsed input with REA's fixed collection bounds applied. */ +export const observeJavaScriptRuntimeInputSchema = + observeJavaScriptRuntimeToolInputSchema.transform((input) => ({ + ...input, + limits: DEFAULT_JAVASCRIPT_RUNTIME_OBSERVATION_LIMITS, + })); export type ObserveJavaScriptRuntimeInput = z.infer< typeof observeJavaScriptRuntimeInputSchema >; diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index c87fda84cf..8c2f32fb84 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import { inspectWebPageWithSourceInputSchema } from "./browserObservation.js"; +import { + DEFAULT_BROWSER_INSPECTION_LIMITS, + inspectWebPageWithSourceInputSchema, +} from "./browserObservation.js"; import { webTextArtifactSchema } from "./webContentArtifact.js"; /** Provider-owned parser and network safety bounds, never caller settings. */ @@ -20,11 +23,13 @@ export const analyzeWebBundleToolInputSchema = export const analyzeWebBundleInputSchema = analyzeWebBundleToolInputSchema.transform((input) => ({ ...input, + limits: DEFAULT_BROWSER_INSPECTION_LIMITS, analysis_limits: WEB_BUNDLE_ANALYSIS_LIMITS, })) as z.ZodType; export type AnalyzeWebBundleInput = z.infer< typeof inspectWebPageWithSourceInputSchema > & { + readonly limits: typeof DEFAULT_BROWSER_INSPECTION_LIMITS; readonly fetch_source_maps: boolean; readonly analysis_limits: typeof WEB_BUNDLE_ANALYSIS_LIMITS; }; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index cc923215e5..5113d92b12 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { - inspectWebPageInputSchema, + inspectWebPageToolInputSchema, webPageInspectionSchema, } from "./browserObservation.js"; import { analyzeCapturedWebBundle } from "./webBundleAnalyzer.js"; @@ -142,7 +142,7 @@ describe("web bundle analyzer", () => { const input = (overrides: Record = {}) => analyzeWebBundleInputSchema.parse({ - ...inspectWebPageInputSchema.parse({ + ...inspectWebPageToolInputSchema.parse({ cdp_endpoint: "http://127.0.0.1:9222", allowed_origins: [origin], target_id: "page-1", diff --git a/src/server/registerBrowserTools.ts b/src/server/registerBrowserTools.ts index db2890e3b2..aca2292031 100644 --- a/src/server/registerBrowserTools.ts +++ b/src/server/registerBrowserTools.ts @@ -19,6 +19,7 @@ import type { ToolContract } from "../contracts/toolContracts.js"; import type { AnalysisError } from "../domain/errors.js"; import type { Evidence } from "../domain/evidence.js"; import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; import type { Result } from "../domain/result.js"; import type { Logger } from "../logger.js"; import { mcpProgressReporter } from "./mcpProgress.js"; @@ -83,10 +84,12 @@ export const registerBrowserTools = ( inspectContract, { input, context }, (parsed, { signal, progress }) => - inspectWebPage(options.browser, options.permissionAuthority, parsed, { - signal, - progress, - }), + inspectWebPage( + options.browser, + options.permissionAuthority, + inspectWebPageInputSchema.parse(parsed), + { signal, progress }, + ), ), ); server.registerTool( diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index bdb826f943..259e8c74a5 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -17,6 +17,7 @@ import type { ToolContract } from "../contracts/toolContracts.js"; import type { AnalysisError } from "../domain/errors.js"; import type { Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; +import { inspectElectronPageInputSchema } from "../domain/electronObservation.js"; import type { Logger } from "../logger.js"; import { mcpProgressReporter } from "./mcpProgress.js"; import { logToolExecution } from "./toolLogging.js"; @@ -81,7 +82,7 @@ export const registerElectronTools = ( inspectElectronPage( options.electron, options.permissionAuthority, - parsed, + inspectElectronPageInputSchema.parse(parsed), { signal, progress, diff --git a/src/server/registerJavaScriptRuntimeObservationTools.ts b/src/server/registerJavaScriptRuntimeObservationTools.ts index 7bcec11dd0..81ddf7df33 100644 --- a/src/server/registerJavaScriptRuntimeObservationTools.ts +++ b/src/server/registerJavaScriptRuntimeObservationTools.ts @@ -12,6 +12,7 @@ import type { ToolContract } from "../contracts/toolContracts.js"; import type { AnalysisError } from "../domain/errors.js"; import type { Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; +import { observeJavaScriptRuntimeInputSchema } from "../domain/javascriptRuntimeObservation.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; @@ -60,7 +61,7 @@ export const registerJavaScriptRuntimeObservationTools = ( observeJavaScriptRuntime( options.runtime, options.permissionAuthority, - parsed, + observeJavaScriptRuntimeInputSchema.parse(parsed), { signal }, ), ), diff --git a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts index 08d843b404..bdb3f4008c 100644 --- a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts @@ -12,19 +12,21 @@ describeBrowser("CdpBrowserProvider: document script 1", () => { it("captures bounded accessibility text only after independent approval", async () => { const browser = await startFakeCdpBrowser(); trackBrowser(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 0, - include_accessibility_text: true, - limits: { - max_ax_text_field_bytes: 6, - max_total_ax_text_bytes: 6, - }, - }), - ); + const request = inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + include_accessibility_text: true, + }); + const result = await new CdpBrowserProvider().inspectPage({ + ...request, + limits: { + ...request.limits, + max_ax_text_field_bytes: 6, + max_total_ax_text_bytes: 6, + }, + }); if (!result.ok) throw result.error; expect(result.value.accessibility).toMatchObject({ @@ -333,29 +335,31 @@ describeBrowser("CdpBrowserProvider: document script 3", () => { it("reports configured truncation without over-reading DOM or script source", async () => { const browser = await startFakeCdpBrowser(); trackBrowser(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 0, - include_script_sources: true, - limits: { - max_frames: 1, - max_dom_nodes: 1, - max_ax_nodes: 1, - max_scripts: 1, - max_resources: 1, - max_workers: 1, - max_storage_keys: 1, - max_script_source_bytes: 10, - max_total_script_source_bytes: 10, - max_network_events: 1, - max_console_events: 1, - max_websocket_events: 1, - }, - }), - ); + const request = inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + include_script_sources: true, + }); + const result = await new CdpBrowserProvider().inspectPage({ + ...request, + limits: { + ...request.limits, + max_frames: 1, + max_dom_nodes: 1, + max_ax_nodes: 1, + max_scripts: 1, + max_resources: 1, + max_workers: 1, + max_storage_keys: 1, + max_script_source_bytes: 10, + max_total_script_source_bytes: 10, + max_network_events: 1, + max_console_events: 1, + max_websocket_events: 1, + }, + }); if (!result.ok) throw result.error; expect(result.value.completeness).toMatchObject({ status: "truncated", @@ -377,29 +381,31 @@ describeBrowser("CdpBrowserProvider: document script 4", () => { it("bounds frames, resources, workers, accessibility, and storage inventories", async () => { const browser = await startFakeCdpBrowser({ extraCollections: true }); trackBrowser(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 0, - include_storage_keys: true, - limits: { - max_frames: 1, - max_dom_nodes: 2, - max_ax_nodes: 1, - max_scripts: 1, - max_resources: 1, - max_workers: 1, - max_storage_keys: 1, - max_script_source_bytes: 10, - max_total_script_source_bytes: 10, - max_network_events: 1, - max_console_events: 1, - max_websocket_events: 1, - }, - }), - ); + const request = inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + include_storage_keys: true, + }); + const result = await new CdpBrowserProvider().inspectPage({ + ...request, + limits: { + ...request.limits, + max_frames: 1, + max_dom_nodes: 2, + max_ax_nodes: 1, + max_scripts: 1, + max_resources: 1, + max_workers: 1, + max_storage_keys: 1, + max_script_source_bytes: 10, + max_total_script_source_bytes: 10, + max_network_events: 1, + max_console_events: 1, + max_websocket_events: 1, + }, + }); if (!result.ok) throw result.error; expect(result.value.completeness).toMatchObject({ status: "truncated", diff --git a/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts b/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts index c08ee2fab2..76b38a909e 100644 --- a/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts @@ -251,25 +251,27 @@ describeBrowser("CdpBrowserProvider: sensitive data 3", () => { it("reports independent sensitive-capture truncation at aggregate byte limits", async () => { const browser = await startFakeCdpBrowser({ sensitiveShapes: true }); trackBrowser(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 0, - include_console_text: true, - include_json_body_shapes: true, - include_websocket_shapes: true, - limits: { - max_console_text_field_bytes: 5, - max_total_console_text_bytes: 5, - max_json_body_bytes: 10, - max_total_json_body_bytes: 10, - max_websocket_shape_bytes: 5, - max_total_websocket_shape_bytes: 5, - }, - }), - ); + const request = inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + include_console_text: true, + include_json_body_shapes: true, + include_websocket_shapes: true, + }); + const result = await new CdpBrowserProvider().inspectPage({ + ...request, + limits: { + ...request.limits, + max_console_text_field_bytes: 5, + max_total_console_text_bytes: 5, + max_json_body_bytes: 10, + max_total_json_body_bytes: 10, + max_websocket_shape_bytes: 5, + max_total_websocket_shape_bytes: 5, + }, + }); if (!result.ok) throw result.error; expect(result.value.console.events[0]?.text_capture).toEqual({ diff --git a/tests/boundary/browser/javascriptRuntimeObservation.test.ts b/tests/boundary/browser/javascriptRuntimeObservation.test.ts index 657167f875..4e03030c4c 100644 --- a/tests/boundary/browser/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/browser/javascriptRuntimeObservation.test.ts @@ -18,6 +18,7 @@ import type { JavaScriptRuntimeObservation, ObserveJavaScriptRuntimeInput, } from "../../../src/domain/javascriptRuntimeObservation.js"; +import { observeJavaScriptRuntimeToolInputSchema } from "../../../src/domain/javascriptRuntimeObservation.js"; import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascriptRuntimeReconciliationSchemas.js"; import type { PermissionCeiling, @@ -27,6 +28,18 @@ import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("passive V8 Inspector provider", () => { + test("keeps collection budgets out of the caller schema", () => { + expect( + observeJavaScriptRuntimeToolInputSchema.safeParse({ + inspector_endpoint: "http://127.0.0.1:9229", + allowed_file_roots: ["/tmp/app"], + allowed_origins: [], + target_id: "target-1", + limits: {}, + }).success, + ).toBe(false); + }); + test("returns every approved Inspector target inline", async () => { const fixture = await runtimeFixture(); const fake = await startFakeV8Inspector({ diff --git a/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts b/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts index 6efbc6d517..240d55630d 100644 --- a/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts +++ b/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js"; import { observeJavaScriptRuntime } from "../../../src/application/JavaScriptRuntimeObservationService.js"; import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js"; +import { observeJavaScriptRuntimeInputSchema } from "../../../src/domain/javascriptRuntimeObservation.js"; import { parseConfig } from "../../../src/config.js"; import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -93,21 +94,14 @@ describe("JavaScript runtime observation CLI parity", () => { const direct = await observeJavaScriptRuntime( new V8InspectorProvider(), authority.value, - { + observeJavaScriptRuntimeInputSchema.parse({ inspector_endpoint: inspector.endpoint, allowed_file_roots: [root], allowed_origins: [], target_id: inspector.targetId, runtime_kind: "node", observation_ms: 10, - limits: { - max_events: 10_000, - max_scripts: 2_000, - max_execution_contexts: 1_000, - max_location_bytes: 16_384, - max_total_metadata_bytes: 4_194_304, - }, - }, + }), ); expect(direct.ok).toBe(true); if (direct.ok) diff --git a/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts index 8352fcc56b..a7e3742a0d 100644 --- a/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts +++ b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts @@ -9,6 +9,7 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js"; import { observeJavaScriptRuntime } from "../../../src/application/JavaScriptRuntimeObservationService.js"; import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js"; +import { observeJavaScriptRuntimeInputSchema } from "../../../src/domain/javascriptRuntimeObservation.js"; import { parseConfig } from "../../../src/config.js"; import { createServer } from "../../../src/server/createServer.js"; import { observed } from "../../fixtures/analysisExecution.js"; @@ -118,21 +119,14 @@ describe("JavaScript runtime observation MCP tools", () => { const direct = await observeJavaScriptRuntime( new V8InspectorProvider(), authority.value, - { + observeJavaScriptRuntimeInputSchema.parse({ inspector_endpoint: inspector.endpoint, allowed_file_roots: [root], allowed_origins: [], target_id: inspector.targetId, runtime_kind: "node", observation_ms: 10, - limits: { - max_events: 10_000, - max_scripts: 2_000, - max_execution_contexts: 1_000, - max_location_bytes: 16_384, - max_total_metadata_bytes: 4_194_304, - }, - }, + }), ); expect(direct.ok).toBe(true); if (direct.ok) expect(evidenceId).toBe(direct.value.evidence_id);