/** * network-intercept.ts — Intercept outgoing fetch, XHR, and WebSocket traffic. * * Patches window.fetch, XMLHttpRequest, and (optionally) WebSocket to log, modify, * mock, block, or delay requests. Useful for: * - Debugging API calls (seeing exactly what headers/body are sent) * - Mocking endpoints during dev without touching code * - Auditing LLM/analytics traffic ("what is careless sending where?") * - Rate-limiting, fault injection, UX testing with slow networks * - Recording network tapes for replay (HAR format) * * SAFETY: This is GLOBAL — affects the whole tab. Call net_intercept_stop when done. * Rules persist across pause/resume; only net_intercept_stop clears them. */ import { tool } from '@strands-agents/sdk' import { z } from 'zod' type Action = 'log' | 'block' | 'mock' | 'delay' | 'modify' type Rule = { id: string match: RegExp action: Action // mock mockStatus?: number mockBody?: string mockHeaders?: Record // delay delayMs?: number // modify (applied AFTER the real response comes back; JSON-only right now) modifyStatus?: number modifyHeaders?: Record modifyBodyPatch?: Record // shallow-merged into parsed JSON response } type Entry = { ts: number method: string url: string status?: number duration_ms: number blocked?: boolean mocked?: boolean modified?: boolean transport: 'fetch' | 'xhr' | 'ws' request_headers?: Record request_body?: string response_headers?: Record response_body?: string error?: string } const STATE = { active: false, paused: false, // paused = still installed, but pass everything through untouched originalFetch: null as typeof fetch | null, originalXHROpen: null as any, originalXHRSend: null as any, originalXHRSetHeader: null as any, originalWebSocket: null as any, rules: [] as Rule[], log: [] as Entry[], maxLog: 500, captureBodies: false, captureHeaders: false, maxBodyBytes: 8000, // cap body captures interceptWS: false, } // -- Helpers ---------------------------------------------------------------- function matchRule(url: string): Rule | undefined { for (const r of STATE.rules) if (r.match.test(url)) return r return undefined } function pushLog(e: Entry) { STATE.log.push(e) if (STATE.log.length > STATE.maxLog) STATE.log.splice(0, STATE.log.length - STATE.maxLog) } function truncate(s: string | undefined | null, max = STATE.maxBodyBytes): string | undefined { if (!s) return undefined if (s.length <= max) return s return s.slice(0, max) + `…[truncated, ${s.length} bytes total]` } function headersToObject(h: Headers | Record | undefined): Record { if (!h) return {} if (h instanceof Headers) { const o: Record = {} h.forEach((v, k) => { o[k] = v }) return o } return { ...h } } function resolveUrl(input: any): string { if (typeof input === 'string') return input if (input instanceof URL) return input.href if (input && typeof input === 'object' && typeof input.url === 'string') return input.url try { return String(input) } catch { return '' } } function resolveMethod(input: any, init: RequestInit | undefined): string { // Request objects carry their method; init.method wins if explicitly set. if (init?.method) return init.method.toUpperCase() if (input && typeof input === 'object' && typeof input.method === 'string') return input.method.toUpperCase() return 'GET' } async function safeExtractReqBody(input: any, init: RequestInit | undefined): Promise { if (!STATE.captureBodies) return undefined try { if (init?.body != null) { if (typeof init.body === 'string') return truncate(init.body) if (init.body instanceof FormData) return truncate('[FormData]') if (init.body instanceof URLSearchParams) return truncate(init.body.toString()) if (init.body instanceof Blob) return truncate(`[Blob ${init.body.size} bytes]`) if (init.body instanceof ArrayBuffer) return truncate(`[ArrayBuffer ${init.body.byteLength} bytes]`) return truncate(String(init.body)) } if (input instanceof Request) { // Clone before reading; original can only be read once. const cloned = input.clone() const text = await cloned.text() return truncate(text) } } catch { return '[body read failed]' } return undefined } // -- Fetch patching --------------------------------------------------------- function installFetch() { if (STATE.originalFetch) return STATE.originalFetch = window.fetch.bind(window) window.fetch = (async (input: any, init?: RequestInit) => { // Paused → passthrough without logging. if (STATE.paused) return STATE.originalFetch!(input, init) const url = resolveUrl(input) const method = resolveMethod(input, init) const start = performance.now() const rule = matchRule(url) const entry: Entry = { ts: Date.now(), method, url, duration_ms: 0, transport: 'fetch' } if (STATE.captureHeaders) { entry.request_headers = headersToObject(init?.headers as any) || (input instanceof Request ? headersToObject(input.headers) : {}) } entry.request_body = await safeExtractReqBody(input, init) try { // BLOCK if (rule?.action === 'block') { entry.blocked = true entry.status = -1 entry.duration_ms = performance.now() - start entry.error = `Blocked by rule ${rule.id}` pushLog(entry) throw new Error(`Blocked by network-intercept rule ${rule.id}: ${url}`) } // DELAY (real fetch after wait) if (rule?.action === 'delay') { await new Promise((r) => setTimeout(r, rule.delayMs || 1000)) } // MOCK (fully synthetic response) if (rule?.action === 'mock') { entry.mocked = true entry.status = rule.mockStatus || 200 entry.duration_ms = performance.now() - start if (STATE.captureBodies) entry.response_body = truncate(rule.mockBody ?? '') if (STATE.captureHeaders) entry.response_headers = rule.mockHeaders || { 'content-type': 'application/json' } pushLog(entry) return new Response(rule.mockBody ?? '', { status: rule.mockStatus || 200, headers: rule.mockHeaders || { 'content-type': 'application/json' }, }) } // Real request const resp = await STATE.originalFetch!(input, init) entry.status = resp.status entry.duration_ms = performance.now() - start if (STATE.captureHeaders) entry.response_headers = headersToObject(resp.headers) // Optionally read body (clones the response so downstream still works). let finalResp = resp if (STATE.captureBodies || rule?.action === 'modify') { try { const cloned = resp.clone() const text = await cloned.text() entry.response_body = truncate(text) if (rule?.action === 'modify') { let newBody = text let newStatus = rule.modifyStatus ?? resp.status let newHeaders = { ...headersToObject(resp.headers), ...(rule.modifyHeaders || {}), } if (rule.modifyBodyPatch) { try { const parsed = text ? JSON.parse(text) : {} const patched = { ...parsed, ...rule.modifyBodyPatch } newBody = JSON.stringify(patched) } catch { // Not JSON — can't patch. Leave body alone. } } entry.modified = true entry.response_body = truncate(newBody) finalResp = new Response(newBody, { status: newStatus, headers: newHeaders }) } } catch { // Body capture failed; carry on with original response. } } pushLog(entry) return finalResp } catch (err) { // Only log errors NOT already logged (e.g. block already logged above). if (!entry.blocked) { entry.duration_ms = performance.now() - start if (!entry.status) entry.status = -1 entry.error = (err as Error)?.message || String(err) pushLog(entry) } throw err } }) as any STATE.active = true } function uninstallFetch() { if (STATE.originalFetch) { window.fetch = STATE.originalFetch STATE.originalFetch = null } } // -- XHR patching (NEW in iter 2) ------------------------------------------- function installXHR() { if (STATE.originalXHROpen) return const OpenProto = XMLHttpRequest.prototype.open const SendProto = XMLHttpRequest.prototype.send const SetHeaderProto = XMLHttpRequest.prototype.setRequestHeader STATE.originalXHROpen = OpenProto STATE.originalXHRSend = SendProto STATE.originalXHRSetHeader = SetHeaderProto XMLHttpRequest.prototype.open = function (this: any, method: string, url: string, async?: boolean, user?: string, pass?: string) { this.__ni_method = method.toUpperCase() this.__ni_url = url this.__ni_headers = {} return OpenProto.apply(this, arguments as any) } XMLHttpRequest.prototype.setRequestHeader = function (this: any, name: string, value: string) { if (STATE.captureHeaders) { this.__ni_headers = this.__ni_headers || {} this.__ni_headers[name] = value } return SetHeaderProto.apply(this, [name, value] as any) } XMLHttpRequest.prototype.send = function (this: any, body?: any) { if (STATE.paused) return SendProto.apply(this, [body] as any) const url = this.__ni_url || '' const method = this.__ni_method || 'GET' const start = performance.now() const rule = matchRule(url) const entry: Entry = { ts: Date.now(), method, url, duration_ms: 0, transport: 'xhr' } if (STATE.captureHeaders) entry.request_headers = this.__ni_headers if (STATE.captureBodies && body != null) { if (typeof body === 'string') entry.request_body = truncate(body) else if (body instanceof FormData) entry.request_body = '[FormData]' else entry.request_body = truncate(String(body)) } // BLOCK — synthesize an error via readystatechange if (rule?.action === 'block') { entry.blocked = true entry.status = -1 entry.duration_ms = performance.now() - start entry.error = `Blocked by rule ${rule.id}` pushLog(entry) // Dispatch as-if network error Object.defineProperty(this, 'readyState', { value: 4, configurable: true }) Object.defineProperty(this, 'status', { value: 0, configurable: true }) setTimeout(() => { try { this.dispatchEvent(new Event('error')) } catch {} try { this.dispatchEvent(new Event('loadend')) } catch {} }, 0) return } // Hook load/error to log results const origOnLoad = this.onload const origOnError = this.onerror const self = this const finish = (status: number, err?: string) => { entry.status = status entry.duration_ms = performance.now() - start if (STATE.captureHeaders) { try { const raw = self.getAllResponseHeaders() entry.response_headers = Object.fromEntries( raw.split('\r\n').filter(Boolean).map((line: string) => { const idx = line.indexOf(':') return [line.slice(0, idx).trim().toLowerCase(), line.slice(idx + 1).trim()] }), ) } catch {} } if (STATE.captureBodies) { try { entry.response_body = truncate(self.responseText) } catch {} } if (err) entry.error = err pushLog(entry) } this.addEventListener('load', () => finish(self.status)) this.addEventListener('error', () => finish(-1, 'xhr error event')) this.addEventListener('abort', () => finish(-1, 'xhr aborted')) const doSend = () => SendProto.apply(this, [body] as any) if (rule?.action === 'delay') { setTimeout(doSend, rule.delayMs || 1000) } else { doSend() } } } function uninstallXHR() { if (STATE.originalXHROpen) { XMLHttpRequest.prototype.open = STATE.originalXHROpen XMLHttpRequest.prototype.send = STATE.originalXHRSend XMLHttpRequest.prototype.setRequestHeader = STATE.originalXHRSetHeader STATE.originalXHROpen = null STATE.originalXHRSend = null STATE.originalXHRSetHeader = null } } // -- WebSocket patching (NEW in iter 3) ------------------------------------- function installWebSocket() { if (STATE.originalWebSocket) return STATE.originalWebSocket = window.WebSocket const Original = STATE.originalWebSocket window.WebSocket = function (this: any, url: string, protocols?: string | string[]) { if (STATE.paused) return new Original(url, protocols) const start = performance.now() const rule = matchRule(url) const entry: Entry = { ts: Date.now(), method: 'WS', url, duration_ms: 0, transport: 'ws' } if (rule?.action === 'block') { entry.blocked = true entry.status = -1 entry.duration_ms = performance.now() - start entry.error = `Blocked by rule ${rule.id}` pushLog(entry) throw new Error(`WebSocket blocked by rule ${rule.id}: ${url}`) } const ws = new Original(url, protocols) ws.addEventListener('open', () => { entry.status = 101 entry.duration_ms = performance.now() - start pushLog({ ...entry }) // log the connect }) ws.addEventListener('error', () => { entry.status = -1 entry.duration_ms = performance.now() - start entry.error = 'ws error' pushLog({ ...entry, ts: Date.now() }) }) ws.addEventListener('close', (ev: any) => { pushLog({ ts: Date.now(), method: 'WS-CLOSE', url, status: ev.code, duration_ms: performance.now() - start, transport: 'ws', }) }) return ws } as any // preserve the static constants (cast because WebSocket.CONNECTING etc. are declared readonly in TS lib) const WS: any = window.WebSocket WS.CONNECTING = 0 WS.OPEN = 1 WS.CLOSING = 2 WS.CLOSED = 3 WS.prototype = Original.prototype } function uninstallWebSocket() { if (STATE.originalWebSocket) { window.WebSocket = STATE.originalWebSocket STATE.originalWebSocket = null } } // -- Public tools ----------------------------------------------------------- export const netInterceptStartTool = tool({ name: 'net_intercept_start', description: 'Start intercepting network traffic. Patches window.fetch by default; pass xhr=true or ws=true to also patch those transports. ' + 'Use capture_bodies=true to include request/response bodies in the log (capped at max_body_bytes). capture_headers=true for headers.', inputSchema: z.object({ maxLog: z.number().optional().describe('Max entries in the rolling log (default 500)'), capture_headers: z.boolean().optional().describe('Capture request + response headers (default false — PII risk)'), capture_bodies: z.boolean().optional().describe('Capture request + response bodies (default false — PII risk, slower)'), max_body_bytes: z.number().optional().describe('Truncate captured bodies to this many bytes (default 8000)'), xhr: z.boolean().optional().describe('Also patch XMLHttpRequest (default false)'), ws: z.boolean().optional().describe('Also patch WebSocket (default false)'), }), callback: (input) => { try { if (input.maxLog) STATE.maxLog = input.maxLog if (input.capture_bodies !== undefined) STATE.captureBodies = input.capture_bodies if (input.capture_headers !== undefined) STATE.captureHeaders = input.capture_headers if (input.max_body_bytes !== undefined) STATE.maxBodyBytes = input.max_body_bytes STATE.paused = false installFetch() if (input.xhr) installXHR() if (input.ws) { STATE.interceptWS = true; installWebSocket() } return JSON.stringify({ status: 'success', active: true, fetch: true, xhr: !!STATE.originalXHROpen, ws: !!STATE.originalWebSocket, capture_headers: STATE.captureHeaders, capture_bodies: STATE.captureBodies, max_body_bytes: STATE.maxBodyBytes, maxLog: STATE.maxLog, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const netInterceptStopTool = tool({ name: 'net_intercept_stop', description: 'Stop intercepting and restore native fetch/XHR/WebSocket. Also clears rules. Use net_intercept_pause to temporarily disable without uninstalling.', inputSchema: z.object({ keep_rules: z.boolean().optional().describe('Keep rules after stop (default false — legacy behavior)'), keep_log: z.boolean().optional().describe('Keep captured log (default true)'), }), callback: (input) => { try { uninstallFetch() uninstallXHR() uninstallWebSocket() STATE.active = false STATE.paused = false if (!input.keep_rules) STATE.rules = [] if (input.keep_log === false) STATE.log = [] return JSON.stringify({ status: 'success', active: false, rules_remaining: STATE.rules.length, log_remaining: STATE.log.length, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const netInterceptPauseTool = tool({ name: 'net_intercept_pause', description: 'Pause interception without uninstalling. Rules + log are retained. Use net_intercept_resume to re-enable.', inputSchema: z.object({}), callback: () => { STATE.paused = true return JSON.stringify({ status: 'success', paused: true }) }, }) export const netInterceptResumeTool = tool({ name: 'net_intercept_resume', description: 'Resume a paused interception.', inputSchema: z.object({}), callback: () => { STATE.paused = false return JSON.stringify({ status: 'success', paused: false }) }, }) export const netAddRuleTool = tool({ name: 'net_intercept_add_rule', description: 'Add a rule. URL pattern is a regex string.\n' + 'Actions:\n' + ' - "log": No-op, just ensures the URL is in the log (matches anything by default)\n' + ' - "block": Reject with an error. Fetch throws, XHR fires error, WS throws.\n' + ' - "mock": Return a synthetic response (provide mockStatus/mockBody/mockHeaders)\n' + ' - "delay": Pass through after delayMs (real response, stalled)\n' + ' - "modify": Let the real request run, then rewrite the response (provide modifyStatus, modifyHeaders, modifyBodyPatch — the patch is shallow-merged into the parsed JSON response).', inputSchema: z.object({ id: z.string(), match: z.string().describe('URL regex, e.g. "api\\.github\\.com"'), action: z.enum(['log', 'block', 'mock', 'delay', 'modify']), mockStatus: z.number().optional(), mockBody: z.string().optional(), mockHeaders: z.record(z.string(), z.string()).optional(), delayMs: z.number().optional(), modifyStatus: z.number().optional(), modifyHeaders: z.record(z.string(), z.string()).optional(), modifyBodyPatch: z.record(z.string(), z.unknown()).optional().describe('Object shallow-merged into JSON response body'), }), callback: (input) => { try { STATE.rules = STATE.rules.filter((r) => r.id !== input.id) STATE.rules.push({ id: input.id, match: new RegExp(input.match), action: input.action, mockStatus: input.mockStatus, mockBody: input.mockBody, mockHeaders: input.mockHeaders, delayMs: input.delayMs, modifyStatus: input.modifyStatus, modifyHeaders: input.modifyHeaders, modifyBodyPatch: input.modifyBodyPatch as any, }) return JSON.stringify({ status: 'success', rules: STATE.rules.length, rule_ids: STATE.rules.map(r => r.id) }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const netRemoveRuleTool = tool({ name: 'net_intercept_remove_rule', description: 'Remove a named rule.', inputSchema: z.object({ id: z.string() }), callback: (input) => { const before = STATE.rules.length STATE.rules = STATE.rules.filter((r) => r.id !== input.id) return JSON.stringify({ status: 'success', removed: before - STATE.rules.length, rules: STATE.rules.length, rule_ids: STATE.rules.map(r => r.id), }) }, }) export const netListRulesTool = tool({ name: 'net_intercept_rules', description: 'List all active rules.', inputSchema: z.object({}), callback: () => { return JSON.stringify({ status: 'success', count: STATE.rules.length, rules: STATE.rules.map(r => ({ id: r.id, match: r.match.source, action: r.action, mockStatus: r.mockStatus, delayMs: r.delayMs, })), }) }, }) export const netGetLogTool = tool({ name: 'net_intercept_log', description: 'Get the captured request log. Filter by URL substring, transport, status, etc.', inputSchema: z.object({ limit: z.number().optional(), filter: z.string().optional().describe('URL substring filter (case-insensitive)'), transport: z.enum(['fetch', 'xhr', 'ws']).optional().describe('Filter by transport'), only_errors: z.boolean().optional().describe('Only show entries with status >=400 or -1'), only_blocked: z.boolean().optional(), only_mocked: z.boolean().optional(), only_modified: z.boolean().optional(), include_bodies: z.boolean().optional().describe('Include body fields in output (default true if captured)'), }), callback: (input) => { let items = STATE.log if (input.filter) { const q = input.filter.toLowerCase() items = items.filter((e) => e.url.toLowerCase().includes(q)) } if (input.transport) items = items.filter((e) => e.transport === input.transport) if (input.only_errors) items = items.filter((e) => (e.status ?? 0) >= 400 || e.status === -1) if (input.only_blocked) items = items.filter((e) => e.blocked) if (input.only_mocked) items = items.filter((e) => e.mocked) if (input.only_modified) items = items.filter((e) => e.modified) if (input.limit) items = items.slice(-input.limit) // Drop large body fields if not requested if (input.include_bodies === false) { items = items.map(({ request_body, response_body, ...rest }) => rest) } return JSON.stringify({ status: 'success', active: STATE.active, paused: STATE.paused, total: STATE.log.length, returned: items.length, entries: items, }) }, }) export const netStatsTool = tool({ name: 'net_intercept_stats', description: 'Aggregate statistics over the captured log: request count / avg+P50+P99 latency / bytes by host, by status class, by transport.', inputSchema: z.object({ filter: z.string().optional().describe('URL substring filter'), }), callback: (input) => { let items = STATE.log if (input.filter) { const q = input.filter.toLowerCase() items = items.filter((e) => e.url.toLowerCase().includes(q)) } if (items.length === 0) { return JSON.stringify({ status: 'success', total: 0, hint: 'Empty log. Call net_intercept_start first.' }) } const byHost: Record = {} const byStatus: Record = {} const byTransport: Record = {} for (const e of items) { let host = '' try { host = new URL(e.url).host } catch {} byHost[host] = byHost[host] || { count: 0, durs: [], errors: 0, blocked: 0, mocked: 0 } byHost[host].count++ byHost[host].durs.push(e.duration_ms) if ((e.status ?? 0) >= 400 || e.status === -1) byHost[host].errors++ if (e.blocked) byHost[host].blocked++ if (e.mocked) byHost[host].mocked++ const statusClass = e.status == null ? 'unknown' : e.status < 0 ? 'error' : `${Math.floor(e.status / 100)}xx` byStatus[statusClass] = (byStatus[statusClass] || 0) + 1 byTransport[e.transport] = (byTransport[e.transport] || 0) + 1 } const percentile = (arr: number[], p: number) => { const sorted = [...arr].sort((a, b) => a - b) const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p)) return sorted[idx] || 0 } const hosts = Object.entries(byHost).map(([host, s]) => ({ host, count: s.count, errors: s.errors, blocked: s.blocked, mocked: s.mocked, avg_ms: Number((s.durs.reduce((a, b) => a + b, 0) / s.durs.length).toFixed(2)), p50_ms: Number(percentile(s.durs, 0.5).toFixed(2)), p99_ms: Number(percentile(s.durs, 0.99).toFixed(2)), })).sort((a, b) => b.count - a.count) return JSON.stringify({ status: 'success', total: items.length, hosts, status_classes: byStatus, transports: byTransport, time_range: { first: new Date(items[0].ts).toISOString(), last: new Date(items[items.length - 1].ts).toISOString(), }, }) }, }) export const netExportHarTool = tool({ name: 'net_intercept_export_har', description: 'Export the captured log as a HAR (HTTP Archive) 1.2 document. Can be imported into Chrome DevTools Network panel for analysis.', inputSchema: z.object({ filter: z.string().optional(), }), callback: (input) => { let items = STATE.log.filter((e) => e.transport !== 'ws') // HAR doesn't model WS cleanly if (input.filter) { const q = input.filter.toLowerCase() items = items.filter((e) => e.url.toLowerCase().includes(q)) } const har = { log: { version: '1.2', creator: { name: 'careless net_intercept', version: '1.0' }, pages: [], entries: items.map((e) => ({ startedDateTime: new Date(e.ts).toISOString(), time: e.duration_ms, request: { method: e.method, url: e.url, httpVersion: 'HTTP/1.1', headers: Object.entries(e.request_headers || {}).map(([name, value]) => ({ name, value })), queryString: [], cookies: [], headersSize: -1, bodySize: e.request_body?.length ?? 0, postData: e.request_body ? { mimeType: 'text/plain', text: e.request_body } : undefined, }, response: { status: e.status ?? 0, statusText: e.error || '', httpVersion: 'HTTP/1.1', headers: Object.entries(e.response_headers || {}).map(([name, value]) => ({ name, value })), cookies: [], content: { size: e.response_body?.length ?? 0, mimeType: e.response_headers?.['content-type'] || 'application/octet-stream', text: e.response_body || '', }, redirectURL: '', headersSize: -1, bodySize: e.response_body?.length ?? 0, }, cache: {}, timings: { send: 0, wait: e.duration_ms, receive: 0 }, _blocked: e.blocked, _mocked: e.mocked, _modified: e.modified, _transport: e.transport, })), }, } return JSON.stringify({ status: 'success', count: items.length, har }) }, }) export const netClearLogTool = tool({ name: 'net_intercept_clear', description: 'Clear the captured request log (rules remain).', inputSchema: z.object({}), callback: () => { const n = STATE.log.length STATE.log = [] return JSON.stringify({ status: 'success', cleared: n }) }, }) export const NETWORK_INTERCEPT_TOOLS = [ netInterceptStartTool, netInterceptStopTool, netInterceptPauseTool, netInterceptResumeTool, netAddRuleTool, netRemoveRuleTool, netListRulesTool, netGetLogTool, netStatsTool, netExportHarTool, netClearLogTool, ]