diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 3bc6475e4..44e24501b 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 0ebeb0a12..0009eea0b 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 811d37e10..4288ac842 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 82457ec0c..d3cc5fa48 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 9918999b1..b8fcea110 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 c1f52cc97..8854dfede 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 3650d3c5a..b5fd8b0dc 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 e9b896c4f..eb1a15b3b 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 dd8bd0ae3..7f89e8d34 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 d14c3203e..e7fbb5bf2 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 2684fbddf..194268068 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 fd38a8e2e..7e985c86c 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 502e89cf7..7e867a156 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 9f1d056d9..0e514ad44 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 c87fda84c..8c2f32fb8 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 cc923215e..5113d92b1 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 db2890e3b..aca229203 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 bdb826f94..259e8c74a 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 7bcec11dd..81ddf7df3 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 08d843b40..bdb3f4008 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 c08ee2fab..76b38a909 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 657167f87..4e03030c4 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 6efbc6d51..240d55630 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 8352fcc56..a7e3742a0 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);