rea-agents 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +34 -12
  2. package/dist/application/AnalysisSnapshotCache.js +8 -6
  3. package/dist/application/ArtifactGraphConstruction.js +11 -2
  4. package/dist/application/ArtifactInventory.js +3 -2
  5. package/dist/application/AuthorizedArtifactInventory.js +2 -1
  6. package/dist/application/BinarySession.js +12 -7
  7. package/dist/application/BoundedJsonFiles.js +6 -29
  8. package/dist/application/BrowserEvidence.js +35 -0
  9. package/dist/application/BrowserObservationPort.js +1 -0
  10. package/dist/application/BrowserObservationService.js +52 -0
  11. package/dist/application/CapabilityInventory.js +7 -0
  12. package/dist/application/ConfiguredRoots.js +19 -0
  13. package/dist/application/CrossVersionInvestigation.js +6 -0
  14. package/dist/application/DeferredFileAuthorization.js +61 -0
  15. package/dist/application/DirectAnalysis.js +38 -18
  16. package/dist/application/EvidenceLedger.js +6 -5
  17. package/dist/application/InvestigationWorkspaceStore.js +2 -26
  18. package/dist/application/ProcessCaptureAuthority.js +3 -2
  19. package/dist/application/ProcessCaptureLifecycle.js +18 -8
  20. package/dist/application/ProcessCheckpoints.js +1 -1
  21. package/dist/application/ProcessCli.js +17 -5
  22. package/dist/application/ProcessHarness.js +6 -19
  23. package/dist/application/ProcessNormalization.js +17 -2
  24. package/dist/application/ProcessOwnership.js +4 -3
  25. package/dist/application/ProcessSampling.js +52 -17
  26. package/dist/application/ProjectPermissionStore.js +106 -3
  27. package/dist/application/ReferenceSourceImportPolicy.js +3 -2
  28. package/dist/application/TerminalRenderer.js +1 -1
  29. package/dist/artifacts/MachOSliceArtifactReader.js +1 -1
  30. package/dist/artifacts/NativeDmgArtifactReader.js +1 -1
  31. package/dist/artifacts/SafeOutputTree.js +3 -3
  32. package/dist/browser/CdpBrowserProvider.js +158 -0
  33. package/dist/browser/CdpCaptureDocuments.js +140 -0
  34. package/dist/browser/CdpCaptureEvents.js +248 -0
  35. package/dist/browser/CdpCaptureStorage.js +53 -0
  36. package/dist/browser/CdpCaptureValues.js +70 -0
  37. package/dist/browser/CdpConnection.js +218 -0
  38. package/dist/browser/CdpEndpoint.js +114 -0
  39. package/dist/browser/CdpOptionalCommand.js +15 -0
  40. package/dist/browser/CdpPageCapture.js +273 -0
  41. package/dist/catalogIdentity.js +2 -0
  42. package/dist/cli.js +2 -0
  43. package/dist/cliBrowserCommands.js +136 -0
  44. package/dist/cliInvestigationCommands.js +19 -34
  45. package/dist/cliPolicyCommands.js +13 -10
  46. package/dist/config.js +44 -0
  47. package/dist/contracts/browserToolContracts.js +80 -0
  48. package/dist/contracts/errorSchemas.js +2 -0
  49. package/dist/contracts/toolContracts.js +3 -5
  50. package/dist/domain/browserObservation.js +361 -0
  51. package/dist/domain/changedBehavior.js +4 -15
  52. package/dist/domain/errors.js +41 -0
  53. package/dist/domain/jsonLimits.js +26 -0
  54. package/dist/domain/permissionPolicy.js +6 -2
  55. package/dist/domain/processCapture.js +3 -1
  56. package/dist/domain/processComparison.js +22 -34
  57. package/dist/domain/reconstructionVerification.js +2 -15
  58. package/dist/domain/reconstructionVerificationSchemas.js +2 -0
  59. package/dist/domain/staticRuntimeCorrelation.js +2 -0
  60. package/dist/domain/symbolAnalysis.js +1 -1
  61. package/dist/generatedPackageMetadata.js +2 -2
  62. package/dist/main.js +6 -0
  63. package/dist/native/CommandRunner.js +11 -4
  64. package/dist/server/createServer.js +7 -0
  65. package/dist/server/registerBrowserTools.js +36 -0
  66. package/dist/server/registerInvestigationTools.js +30 -35
  67. package/dist/server/registerProcessComparisonTool.js +2 -0
  68. package/dist/server/registerSessionTools.js +15 -2
  69. package/dist/server/runDerivedOperation.js +10 -2
  70. package/package.json +2 -1
  71. package/scripts/rea.mjs +1 -1
  72. package/skills/rea-analysis/SKILL.md +18 -3
@@ -0,0 +1,158 @@
1
+ import { browserTargetListSchema, sanitizeBrowserUrl, webPageInspectionSchema, } from "../domain/browserObservation.js";
2
+ import { AnalysisError, BrowserObservationError, ProviderAdapterError, } from "../domain/errors.js";
3
+ import { err, ok } from "../domain/result.js";
4
+ import { CdpConnection } from "./CdpConnection.js";
5
+ import { discoverCdpEndpoint, } from "./CdpEndpoint.js";
6
+ import { capturePage } from "./CdpPageCapture.js";
7
+ const IDENTITY = {
8
+ id: "rea-cdp-browser",
9
+ name: "REA Chrome DevTools Protocol observation provider",
10
+ version: "1",
11
+ };
12
+ /** Passive, origin-scoped browser observation through a user-owned CDP endpoint. */
13
+ export class CdpBrowserProvider {
14
+ identity() {
15
+ return IDENTITY;
16
+ }
17
+ async listTargets(input, options = {}) {
18
+ try {
19
+ const discovery = await discoverCdpEndpoint(input.cdp_endpoint, "list_browser_targets", options.signal);
20
+ const allowedOrigins = new Set(input.allowed_origins);
21
+ const selected = selectTargets(discovery, allowedOrigins);
22
+ const items = selected.allowed.slice(input.offset, input.offset + input.limit);
23
+ const nextOffset = input.offset + items.length;
24
+ return ok(browserTargetListSchema.parse({
25
+ schema_version: 1,
26
+ browser: discovery.version,
27
+ targets: {
28
+ items,
29
+ offset: input.offset,
30
+ limit: input.limit,
31
+ total: selected.allowed.length,
32
+ next_offset: nextOffset < selected.allowed.length ? nextOffset : null,
33
+ has_more: nextOffset < selected.allowed.length,
34
+ },
35
+ excluded: selected.excluded,
36
+ limitations: [
37
+ "Only page targets whose current URL matches an approved exact origin are listed.",
38
+ ],
39
+ }));
40
+ }
41
+ catch (cause) {
42
+ return err(providerError(cause, "list_browser_targets"));
43
+ }
44
+ }
45
+ async inspectPage(input, options = {}) {
46
+ let connection;
47
+ let sessionId;
48
+ try {
49
+ const discovery = await discoverCdpEndpoint(input.cdp_endpoint, "inspect_web_page", options.signal);
50
+ const target = authorizeTarget(discovery, input);
51
+ connection = await CdpConnection.connect(discovery.browserWebSocketUrl, options.signal);
52
+ const attached = await connection.send("Target.attachToTarget", { targetId: target.id, flatten: true }, undefined, options.signal);
53
+ sessionId = attachedSessionId(attached);
54
+ const result = await capturePage({
55
+ connection,
56
+ sessionId,
57
+ discovery,
58
+ target,
59
+ input,
60
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
61
+ ...(options.progress === undefined
62
+ ? {}
63
+ : { progress: options.progress }),
64
+ });
65
+ return ok(webPageInspectionSchema.parse(result));
66
+ }
67
+ catch (cause) {
68
+ return err(providerError(cause, "inspect_web_page"));
69
+ }
70
+ finally {
71
+ if (connection !== undefined) {
72
+ if (sessionId !== undefined)
73
+ await bestEffortCleanup(connection, sessionId);
74
+ await connection.close();
75
+ }
76
+ }
77
+ }
78
+ }
79
+ const selectTargets = (discovery, allowedOrigins) => {
80
+ const allowed = [];
81
+ let disallowedOrigin = 0;
82
+ let unsupportedUrl = 0;
83
+ let nonPage = 0;
84
+ for (const target of discovery.targets) {
85
+ if (target.type !== "page") {
86
+ nonPage += 1;
87
+ continue;
88
+ }
89
+ const url = sanitizeBrowserUrl(target.url);
90
+ if (url.origin === null) {
91
+ unsupportedUrl += 1;
92
+ continue;
93
+ }
94
+ if (!allowedOrigins.has(url.origin)) {
95
+ disallowedOrigin += 1;
96
+ continue;
97
+ }
98
+ allowed.push({
99
+ target_id: target.id,
100
+ type: target.type,
101
+ title: target.title.slice(0, 16_384),
102
+ url: url.url,
103
+ origin: url.origin,
104
+ attached: target.attached,
105
+ });
106
+ }
107
+ allowed.sort((left, right) => left.target_id.localeCompare(right.target_id));
108
+ return {
109
+ allowed,
110
+ excluded: {
111
+ disallowed_origin: disallowedOrigin,
112
+ unsupported_url: unsupportedUrl,
113
+ non_page: nonPage,
114
+ },
115
+ };
116
+ };
117
+ const authorizeTarget = (discovery, input) => {
118
+ const target = discovery.targets.find((candidate) => candidate.id === input.target_id);
119
+ if (target === undefined)
120
+ throw new BrowserObservationError("inspect_web_page", "target_not_found");
121
+ const origin = sanitizeBrowserUrl(target.url).origin;
122
+ if (target.type !== "page" ||
123
+ origin === null ||
124
+ !input.allowed_origins.includes(origin))
125
+ throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
126
+ return target;
127
+ };
128
+ const attachedSessionId = (value) => {
129
+ if (typeof value !== "object" || value === null || !("sessionId" in value))
130
+ throw new BrowserObservationError("inspect_web_page", "protocol_error");
131
+ if (typeof value.sessionId !== "string" || value.sessionId.length > 256)
132
+ throw new BrowserObservationError("inspect_web_page", "protocol_error");
133
+ return value.sessionId;
134
+ };
135
+ const bestEffortCleanup = async (connection, sessionId) => {
136
+ for (const method of [
137
+ "Network.disable",
138
+ "Debugger.disable",
139
+ "Runtime.disable",
140
+ "Page.disable",
141
+ ]) {
142
+ try {
143
+ await connection.send(method, {}, sessionId);
144
+ }
145
+ catch {
146
+ // Cleanup continues so detach is still attempted.
147
+ }
148
+ }
149
+ try {
150
+ await connection.send("Target.detachFromTarget", { sessionId });
151
+ }
152
+ catch {
153
+ // Closing REA's socket is the final non-destructive cleanup boundary.
154
+ }
155
+ };
156
+ const providerError = (cause, operation) => cause instanceof AnalysisError
157
+ ? cause
158
+ : new ProviderAdapterError(IDENTITY.id, operation, { cause });
@@ -0,0 +1,140 @@
1
+ import { allowedSanitizedUrl, boundedText, numberValue, recordValue, recordsValue, requiredRecord, stringValue, } from "./CdpCaptureValues.js";
2
+ /** Normalize allowed frames while retaining a bounded prefix and total count. */
3
+ export const captureFrames = (result, allowedOrigins, maximum) => {
4
+ const root = recordValue(requiredRecord(result).frameTree);
5
+ if (root === undefined)
6
+ return { total: 0, items: [] };
7
+ const items = [];
8
+ let total = 0;
9
+ for (const tree of walkFrameTrees(root)) {
10
+ const frame = recordValue(tree.frame);
11
+ const frameId = stringValue(frame?.id);
12
+ const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
13
+ if (frame === undefined ||
14
+ frameId === undefined ||
15
+ frameId.length > 256 ||
16
+ sanitized === undefined ||
17
+ sanitized.origin === null)
18
+ continue;
19
+ total += 1;
20
+ if (items.length >= maximum)
21
+ continue;
22
+ items.push({
23
+ frame_id: frameId,
24
+ parent_frame_id: (stringValue(frame.parentId) ?? "").slice(0, 256) || null,
25
+ url: sanitized.url,
26
+ origin: sanitized.origin,
27
+ });
28
+ }
29
+ return { total, items };
30
+ };
31
+ /** Read the current main-frame URL from an untrusted Page.getFrameTree result. */
32
+ export const mainFrameUrl = (result) => stringValue(recordValue(recordValue(requiredRecord(result).frameTree)?.frame)?.url);
33
+ /** Normalize allowed resources while retaining a bounded prefix and total count. */
34
+ export const captureResources = (result, allowedOrigins, maximum) => {
35
+ const root = recordValue(requiredRecord(result).frameTree);
36
+ if (root === undefined)
37
+ return { total: 0, items: [] };
38
+ const items = [];
39
+ let total = 0;
40
+ for (const tree of walkFrameTrees(root)) {
41
+ for (const resource of recordsValue(tree.resources)) {
42
+ const url = allowedSanitizedUrl(resource.url, allowedOrigins);
43
+ if (url === undefined || url.origin === null)
44
+ continue;
45
+ total += 1;
46
+ if (items.length >= maximum)
47
+ continue;
48
+ const contentSize = numberValue(resource.contentSize);
49
+ items.push({
50
+ url: url.url,
51
+ origin: url.origin,
52
+ type: (stringValue(resource.type) ?? "Other").slice(0, 100),
53
+ mime_type: (stringValue(resource.mimeType) ?? "").slice(0, 256),
54
+ content_size: contentSize === undefined ? null : Math.max(0, contentSize),
55
+ });
56
+ }
57
+ }
58
+ return { total, items };
59
+ };
60
+ const walkFrameTrees = function* (root) {
61
+ const pending = [root];
62
+ while (pending.length > 0) {
63
+ const tree = pending.pop();
64
+ if (tree === undefined)
65
+ return;
66
+ const children = recordsValue(tree.childFrames);
67
+ for (let index = children.length - 1; index >= 0; index -= 1) {
68
+ const child = children[index];
69
+ if (child !== undefined)
70
+ pending.push(child);
71
+ }
72
+ yield tree;
73
+ }
74
+ };
75
+ export const captureDom = (result, allowedOrigins, input) => {
76
+ const root = requiredRecord(result);
77
+ const strings = Array.isArray(root.strings)
78
+ ? root.strings.map((value) => stringValue(value) ?? "")
79
+ : [];
80
+ const nodes = [];
81
+ let total = 0;
82
+ for (const document of recordsValue(root.documents)) {
83
+ const documentUrl = indexedString(strings, document.documentURL);
84
+ if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined)
85
+ continue;
86
+ const documentNodes = recordValue(document.nodes);
87
+ if (documentNodes === undefined)
88
+ continue;
89
+ const nodeTypes = numberArray(documentNodes.nodeType);
90
+ const nodeNames = numberArray(documentNodes.nodeName);
91
+ const nodeValues = numberArray(documentNodes.nodeValue);
92
+ const parents = numberArray(documentNodes.parentIndex);
93
+ const attributes = Array.isArray(documentNodes.attributes)
94
+ ? documentNodes.attributes
95
+ : [];
96
+ const baseIndex = nodes.length;
97
+ total += nodeTypes.length;
98
+ for (let index = 0; index < nodeTypes.length && nodes.length < input.limits.max_dom_nodes; index += 1) {
99
+ const attributeIndexes = numberArray(attributes[index]);
100
+ const parent = Math.trunc(parents[index] ?? -1);
101
+ nodes.push({
102
+ index: nodes.length,
103
+ parent_index: parent < 0 ? -1 : baseIndex + parent,
104
+ node_type: Math.max(0, Math.trunc(nodeTypes[index] ?? 0)),
105
+ node_name: indexedString(strings, nodeNames[index]).slice(0, 256),
106
+ node_value_length: indexedString(strings, nodeValues[index]).length,
107
+ attribute_names: attributeIndexes
108
+ .filter((_value, attributeIndex) => attributeIndex % 2 === 0)
109
+ .map((value) => indexedString(strings, value).slice(0, 256))
110
+ .slice(0, 200),
111
+ });
112
+ }
113
+ }
114
+ return { total, nodes };
115
+ };
116
+ export const captureAccessibility = (results, maximum) => {
117
+ const all = results.flatMap((result) => recordsValue(requiredRecord(result).nodes));
118
+ return {
119
+ total: all.length,
120
+ nodes: all.slice(0, maximum).map((node) => ({
121
+ node_id: (stringValue(node.nodeId) ?? "").slice(0, 256),
122
+ parent_id: (stringValue(node.parentId) ?? "").slice(0, 256) || null,
123
+ role: axText(node.role),
124
+ name: axText(node.name),
125
+ description: axText(node.description),
126
+ ignored: node.ignored === true,
127
+ })),
128
+ };
129
+ };
130
+ const axText = (value) => boundedText(recordValue(value)?.value, 1_024);
131
+ const indexedString = (strings, index) => {
132
+ const integer = numberValue(index);
133
+ return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? "");
134
+ };
135
+ const numberArray = (value) => Array.isArray(value)
136
+ ? value.flatMap((item) => {
137
+ const number = numberValue(item);
138
+ return number === undefined ? [] : [number];
139
+ })
140
+ : [];
@@ -0,0 +1,248 @@
1
+ import { allowedSanitizedUrl, boundedText, isHttpUrl, numberValue, recordValue, recordsValue, stringValue, } from "./CdpCaptureValues.js";
2
+ /** Bounded event accumulator that drops payload values at ingestion time. */
3
+ export class CdpCaptureEvents {
4
+ input;
5
+ allowedOrigins;
6
+ scripts = new Map();
7
+ network = new Map();
8
+ allowedWebSockets = new Set();
9
+ console = [];
10
+ websockets = [];
11
+ dropped = 0;
12
+ originViolation = false;
13
+ navigationDuringCapture = false;
14
+ #mainFrameId;
15
+ constructor(input, allowedOrigins) {
16
+ this.input = input;
17
+ this.allowedOrigins = allowedOrigins;
18
+ }
19
+ beginAuthorizedFrame(frameId) {
20
+ this.#mainFrameId = frameId;
21
+ this.originViolation = false;
22
+ this.navigationDuringCapture = false;
23
+ this.dropped = 0;
24
+ this.scripts.clear();
25
+ this.network.clear();
26
+ this.allowedWebSockets.clear();
27
+ this.console.length = 0;
28
+ this.websockets.length = 0;
29
+ }
30
+ beginFinalCapture(frameId) {
31
+ this.#mainFrameId = frameId;
32
+ this.navigationDuringCapture = false;
33
+ }
34
+ ingest(event) {
35
+ const params = recordValue(event.params);
36
+ if (params === undefined)
37
+ return;
38
+ switch (event.method) {
39
+ case "Debugger.scriptParsed":
40
+ this.#script(params);
41
+ break;
42
+ case "Network.requestWillBeSent":
43
+ this.#request(params);
44
+ break;
45
+ case "Network.responseReceived":
46
+ this.#response(params);
47
+ break;
48
+ case "Network.loadingFinished":
49
+ this.#finished(params);
50
+ break;
51
+ case "Runtime.consoleAPICalled":
52
+ this.#console(params);
53
+ break;
54
+ case "Page.frameNavigated":
55
+ this.#frameNavigated(params);
56
+ break;
57
+ case "Network.webSocketCreated":
58
+ this.#websocketCreated(params);
59
+ break;
60
+ case "Network.webSocketFrameSent":
61
+ this.#websocket(params, "sent");
62
+ break;
63
+ case "Network.webSocketFrameReceived":
64
+ this.#websocket(params, "received");
65
+ break;
66
+ }
67
+ }
68
+ #script(params) {
69
+ const scriptId = stringValue(params.scriptId);
70
+ const rawUrl = stringValue(params.url) ?? "";
71
+ const sanitized = allowedSanitizedUrl(rawUrl, this.allowedOrigins);
72
+ if (scriptId === undefined ||
73
+ scriptId.length > 256 ||
74
+ sanitized === undefined)
75
+ return;
76
+ if (this.scripts.size >= this.input.limits.max_scripts &&
77
+ !this.scripts.has(scriptId)) {
78
+ this.dropped += 1;
79
+ return;
80
+ }
81
+ this.scripts.set(scriptId, {
82
+ scriptId,
83
+ url: sanitized.url,
84
+ origin: sanitized.origin,
85
+ hash: (stringValue(params.hash) ?? "").slice(0, 512),
86
+ length: Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.trunc(numberValue(params.length) ?? 0))),
87
+ isModule: params.isModule === true,
88
+ language: boundedText(params.scriptLanguage, 100),
89
+ sourceMapUrl: this.#sourceMap(params.sourceMapURL),
90
+ });
91
+ }
92
+ #sourceMap(value) {
93
+ const sanitized = allowedSanitizedUrl(value, this.allowedOrigins);
94
+ return sanitized?.url ?? null;
95
+ }
96
+ #request(params) {
97
+ const requestId = stringValue(params.requestId);
98
+ const request = recordValue(params.request);
99
+ const sanitized = allowedSanitizedUrl(request?.url, this.allowedOrigins);
100
+ if (requestId === undefined || requestId.length > 256)
101
+ return;
102
+ if (request === undefined || sanitized === undefined) {
103
+ this.network.delete(requestId);
104
+ return;
105
+ }
106
+ if (this.network.size >= this.input.limits.max_network_events &&
107
+ !this.network.has(requestId)) {
108
+ this.dropped += 1;
109
+ return;
110
+ }
111
+ const initiator = recordValue(params.initiator);
112
+ const initiatorUrl = allowedSanitizedUrl(firstCallFrame(initiator)?.url, this.allowedOrigins);
113
+ this.network.set(requestId, {
114
+ request_id: requestId,
115
+ url: sanitized.url,
116
+ origin: sanitized.origin ?? "",
117
+ method: (stringValue(request.method) ?? "GET").slice(0, 32),
118
+ resource_type: boundedText(params.type, 100),
119
+ status: null,
120
+ mime_type: null,
121
+ encoded_data_length: null,
122
+ initiator: {
123
+ type: (stringValue(initiator?.type) ?? "other").slice(0, 100),
124
+ url: initiatorUrl?.url ?? null,
125
+ line: integerOrNull(firstCallFrame(initiator)?.lineNumber),
126
+ column: integerOrNull(firstCallFrame(initiator)?.columnNumber),
127
+ },
128
+ });
129
+ }
130
+ #response(params) {
131
+ const requestId = stringValue(params.requestId);
132
+ if (requestId === undefined || requestId.length > 256)
133
+ return;
134
+ const current = this.network.get(requestId);
135
+ const response = recordValue(params.response);
136
+ const sanitized = allowedSanitizedUrl(response?.url, this.allowedOrigins);
137
+ if (current === undefined)
138
+ return;
139
+ if (response === undefined || sanitized === undefined) {
140
+ this.network.delete(requestId);
141
+ return;
142
+ }
143
+ this.network.set(requestId, {
144
+ ...current,
145
+ status: numberValue(response.status) ?? null,
146
+ mime_type: boundedText(response.mimeType, 256),
147
+ });
148
+ }
149
+ #finished(params) {
150
+ const requestId = stringValue(params.requestId);
151
+ if (requestId === undefined || requestId.length > 256)
152
+ return;
153
+ const current = this.network.get(requestId);
154
+ if (current === undefined)
155
+ return;
156
+ this.network.set(requestId, {
157
+ ...current,
158
+ encoded_data_length: Math.max(0, numberValue(params.encodedDataLength) ?? 0),
159
+ });
160
+ }
161
+ #console(params) {
162
+ const frame = firstCallFrame(recordValue(params.stackTrace));
163
+ const source = allowedSanitizedUrl(frame?.url, this.allowedOrigins);
164
+ if (frame === undefined || source === undefined)
165
+ return;
166
+ if (this.console.length >= this.input.limits.max_console_events) {
167
+ this.dropped += 1;
168
+ return;
169
+ }
170
+ this.console.push({
171
+ type: (stringValue(params.type) ?? "unknown").slice(0, 100),
172
+ timestamp: numberValue(params.timestamp) ?? 0,
173
+ argument_types: recordsValue(params.args)
174
+ .map((argument) => (stringValue(argument.type) ?? "unknown").slice(0, 100))
175
+ .slice(0, 100),
176
+ url: source?.url ?? null,
177
+ line: integerOrNull(frame?.lineNumber),
178
+ column: integerOrNull(frame?.columnNumber),
179
+ });
180
+ }
181
+ #websocket(params, direction) {
182
+ const requestId = stringValue(params.requestId);
183
+ if (requestId === undefined ||
184
+ requestId.length > 256 ||
185
+ (!this.network.has(requestId) && !this.allowedWebSockets.has(requestId)))
186
+ return;
187
+ if (this.websockets.length >= this.input.limits.max_websocket_events) {
188
+ this.dropped += 1;
189
+ return;
190
+ }
191
+ const response = recordValue(params.response);
192
+ const payload = stringValue(response?.payloadData) ?? "";
193
+ const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
194
+ this.websockets.push({
195
+ request_id: requestId,
196
+ direction,
197
+ opcode,
198
+ payload_bytes: opcode === 1
199
+ ? Buffer.byteLength(payload)
200
+ : Buffer.from(payload, "base64").byteLength,
201
+ });
202
+ }
203
+ #websocketCreated(params) {
204
+ const requestId = stringValue(params.requestId);
205
+ const rawUrl = stringValue(params.url);
206
+ if (requestId === undefined ||
207
+ requestId.length > 256 ||
208
+ rawUrl === undefined)
209
+ return;
210
+ let parsed;
211
+ try {
212
+ parsed = new URL(rawUrl);
213
+ }
214
+ catch {
215
+ return;
216
+ }
217
+ if (parsed.protocol === "ws:")
218
+ parsed.protocol = "http:";
219
+ else if (parsed.protocol === "wss:")
220
+ parsed.protocol = "https:";
221
+ else
222
+ return;
223
+ if (this.allowedOrigins.has(parsed.origin) &&
224
+ (this.allowedWebSockets.has(requestId) ||
225
+ this.allowedWebSockets.size < this.input.limits.max_websocket_events))
226
+ this.allowedWebSockets.add(requestId);
227
+ else if (this.allowedOrigins.has(parsed.origin))
228
+ this.dropped += 1;
229
+ }
230
+ #frameNavigated(params) {
231
+ const frame = recordValue(params.frame);
232
+ if (stringValue(frame?.id) !== this.#mainFrameId)
233
+ return;
234
+ this.navigationDuringCapture = true;
235
+ const rawUrl = stringValue(frame?.url);
236
+ if (isHttpUrl(rawUrl) &&
237
+ allowedSanitizedUrl(rawUrl, this.allowedOrigins) === undefined)
238
+ this.originViolation = true;
239
+ }
240
+ }
241
+ const firstCallFrame = (value) => {
242
+ const callFrames = recordsValue(value?.callFrames);
243
+ return callFrames[0];
244
+ };
245
+ const integerOrNull = (value) => {
246
+ const number = numberValue(value);
247
+ return number === undefined ? null : Math.max(0, Math.trunc(number));
248
+ };
@@ -0,0 +1,53 @@
1
+ import { CdpConnection } from "./CdpConnection.js";
2
+ import { optionalCdpCommand } from "./CdpOptionalCommand.js";
3
+ import { numberValue, recordValue, recordsValue, stringValue, } from "./CdpCaptureValues.js";
4
+ /** Capture redacted, bounded storage metadata for one authorized origin. */
5
+ export const captureStorage = async (context, origin, limitations) => {
6
+ const quota = recordValue(await optionalCdpCommand(context, "Storage.getUsageAndQuota", { origin }, limitations));
7
+ const local = context.input.include_storage_keys
8
+ ? await storageKeys(context, origin, true, limitations)
9
+ : [];
10
+ const session = context.input.include_storage_keys
11
+ ? await storageKeys(context, origin, false, limitations)
12
+ : [];
13
+ const indexed = context.input.include_storage_keys
14
+ ? stringArray(recordValue(await optionalCdpCommand(context, "IndexedDB.requestDatabaseNames", { securityOrigin: origin }, limitations))?.databaseNames)
15
+ : [];
16
+ const caches = context.input.include_storage_keys
17
+ ? recordsValue(recordValue(await optionalCdpCommand(context, "CacheStorage.requestCacheNames", { securityOrigin: origin }, limitations))?.caches).flatMap((cache) => {
18
+ const name = stringValue(cache.cacheName);
19
+ return name === undefined ? [] : [name.slice(0, 1_024)];
20
+ })
21
+ : [];
22
+ const maximum = context.input.limits.max_storage_keys;
23
+ return {
24
+ value: {
25
+ origin,
26
+ usage_bytes: numberValue(quota?.usage) ?? null,
27
+ quota_bytes: numberValue(quota?.quota) ?? null,
28
+ local_storage_keys: local.slice(0, maximum),
29
+ session_storage_keys: session.slice(0, maximum),
30
+ indexed_db_names: indexed.slice(0, maximum),
31
+ cache_names: caches.slice(0, maximum),
32
+ values_redacted: true,
33
+ },
34
+ truncated: [local, session, indexed, caches].some((items) => items.length > maximum),
35
+ };
36
+ };
37
+ const storageKeys = async (context, origin, isLocalStorage, limitations) => {
38
+ const result = recordValue(await optionalCdpCommand(context, "DOMStorage.getDOMStorageItems", { storageId: { securityOrigin: origin, isLocalStorage } }, limitations));
39
+ return Array.isArray(result?.entries)
40
+ ? result.entries.flatMap((entry) => {
41
+ if (!Array.isArray(entry))
42
+ return [];
43
+ const key = stringValue(entry[0]);
44
+ return key === undefined ? [] : [key.slice(0, 1_024)];
45
+ })
46
+ : [];
47
+ };
48
+ const stringArray = (value) => Array.isArray(value)
49
+ ? value.flatMap((item) => {
50
+ const text = stringValue(item);
51
+ return text === undefined ? [] : [text.slice(0, 1_024)];
52
+ })
53
+ : [];
@@ -0,0 +1,70 @@
1
+ import { createHash } from "node:crypto";
2
+ import { AnalysisCancelledError, BrowserObservationError, } from "../domain/errors.js";
3
+ import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
4
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5
+ export const recordValue = (value) => isRecord(value) ? value : undefined;
6
+ export const recordsValue = (value) => Array.isArray(value)
7
+ ? value.flatMap((item) => {
8
+ const record = recordValue(item);
9
+ return record === undefined ? [] : [record];
10
+ })
11
+ : [];
12
+ export const stringValue = (value) => typeof value === "string" ? value : undefined;
13
+ export const numberValue = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
14
+ export const requiredRecord = (value) => {
15
+ const record = recordValue(value);
16
+ if (record === undefined)
17
+ throw new BrowserObservationError("inspect_web_page", "protocol_error");
18
+ return record;
19
+ };
20
+ export const boundedText = (value, maximum = 1_024) => {
21
+ const text = stringValue(value);
22
+ return text === undefined ? null : text.slice(0, maximum);
23
+ };
24
+ export const isHttpUrl = (value) => {
25
+ if (value === undefined)
26
+ return false;
27
+ try {
28
+ const protocol = new URL(value).protocol;
29
+ return protocol === "http:" || protocol === "https:";
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ };
35
+ export const allowedSanitizedUrl = (value, allowedOrigins) => {
36
+ const text = stringValue(value);
37
+ if (text === undefined)
38
+ return undefined;
39
+ const sanitized = sanitizeBrowserUrl(text);
40
+ return sanitized.origin !== null && allowedOrigins.has(sanitized.origin)
41
+ ? sanitized
42
+ : undefined;
43
+ };
44
+ export const sourceResult = (source) => ({
45
+ included: true,
46
+ sha256: createHash("sha256").update(source).digest("hex"),
47
+ bytes: Buffer.byteLength(source),
48
+ content: source,
49
+ });
50
+ export const sourceExcluded = (reason) => ({ included: false, reason });
51
+ export const delayWithCancellation = async (durationMs, signal) => {
52
+ if (durationMs === 0)
53
+ return;
54
+ if (signal?.aborted === true)
55
+ throw new AnalysisCancelledError("inspect_web_page");
56
+ await new Promise((resolve, reject) => {
57
+ const timer = setTimeout(() => {
58
+ signal?.removeEventListener("abort", onAbort);
59
+ resolve();
60
+ }, durationMs);
61
+ const onAbort = () => {
62
+ clearTimeout(timer);
63
+ signal?.removeEventListener("abort", onAbort);
64
+ reject(new AnalysisCancelledError("inspect_web_page"));
65
+ };
66
+ signal?.addEventListener("abort", onAbort, { once: true });
67
+ if (signal?.aborted === true)
68
+ onAbort();
69
+ });
70
+ };