@wrongstack/tools 0.287.0 → 0.289.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 (93) hide show
  1. package/dist/_fetch-guard.d.ts.map +1 -1
  2. package/dist/_redact-command.d.ts.map +1 -1
  3. package/dist/_spawn-stream.d.ts.map +1 -1
  4. package/dist/audit.js +61 -14
  5. package/dist/audit.js.map +3 -3
  6. package/dist/bash.d.ts +1 -1
  7. package/dist/bash.d.ts.map +1 -1
  8. package/dist/bash.js +97 -19
  9. package/dist/bash.js.map +3 -3
  10. package/dist/builtin.d.ts +12 -10
  11. package/dist/builtin.d.ts.map +1 -1
  12. package/dist/builtin.js +1620 -803
  13. package/dist/builtin.js.map +3 -3
  14. package/dist/codebase-index/background-indexer.d.ts.map +1 -1
  15. package/dist/codebase-index/bm25.d.ts +1 -2
  16. package/dist/codebase-index/bm25.d.ts.map +1 -1
  17. package/dist/codebase-index/codebase-index-tool.d.ts.map +1 -1
  18. package/dist/codebase-index/codebase-search-tool.d.ts.map +1 -1
  19. package/dist/codebase-index/codebase-stats-tool.d.ts +7 -0
  20. package/dist/codebase-index/codebase-stats-tool.d.ts.map +1 -1
  21. package/dist/codebase-index/index-service.d.ts +11 -1
  22. package/dist/codebase-index/index-service.d.ts.map +1 -1
  23. package/dist/codebase-index/index.d.ts +2 -1
  24. package/dist/codebase-index/index.d.ts.map +1 -1
  25. package/dist/codebase-index/index.js +749 -192
  26. package/dist/codebase-index/index.js.map +3 -3
  27. package/dist/codebase-index/indexer.d.ts.map +1 -1
  28. package/dist/codebase-index/schema.d.ts +43 -1
  29. package/dist/codebase-index/schema.d.ts.map +1 -1
  30. package/dist/codebase-index/ts-parser.d.ts.map +1 -1
  31. package/dist/codebase-index/worker-protocol.d.ts +1 -0
  32. package/dist/codebase-index/worker-protocol.d.ts.map +1 -1
  33. package/dist/codebase-index/worker.js +610 -152
  34. package/dist/codebase-index/worker.js.map +3 -3
  35. package/dist/codebase-index/writer.d.ts +27 -1
  36. package/dist/codebase-index/writer.d.ts.map +1 -1
  37. package/dist/e2e.js +477 -0
  38. package/dist/e2e.js.map +7 -0
  39. package/dist/exec.d.ts +1 -1
  40. package/dist/exec.d.ts.map +1 -1
  41. package/dist/exec.js +60 -5
  42. package/dist/exec.js.map +3 -3
  43. package/dist/fetch.js +2 -2
  44. package/dist/fetch.js.map +2 -2
  45. package/dist/format.js +61 -14
  46. package/dist/format.js.map +3 -3
  47. package/dist/glob.js +2 -2
  48. package/dist/glob.js.map +1 -1
  49. package/dist/grep.d.ts.map +1 -1
  50. package/dist/grep.js +2 -2
  51. package/dist/grep.js.map +2 -2
  52. package/dist/index.d.ts +3 -2
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1786 -809
  55. package/dist/index.js.map +3 -3
  56. package/dist/install.js +61 -14
  57. package/dist/install.js.map +3 -3
  58. package/dist/kanban.d.ts +11 -2
  59. package/dist/kanban.d.ts.map +1 -1
  60. package/dist/kanban.js +695 -571
  61. package/dist/kanban.js.map +3 -3
  62. package/dist/languages/detect.d.ts.map +1 -1
  63. package/dist/languages/index.js +61 -14
  64. package/dist/languages/index.js.map +3 -3
  65. package/dist/languages/types.d.ts +7 -0
  66. package/dist/languages/types.d.ts.map +1 -1
  67. package/dist/lint.js +61 -14
  68. package/dist/lint.js.map +3 -3
  69. package/dist/outdated.js +61 -14
  70. package/dist/outdated.js.map +3 -3
  71. package/dist/pack.js +1617 -800
  72. package/dist/pack.js.map +3 -3
  73. package/dist/plan.js +4 -0
  74. package/dist/plan.js.map +2 -2
  75. package/dist/process-registry.js +12 -5
  76. package/dist/process-registry.js.map +2 -2
  77. package/dist/search.js +2 -2
  78. package/dist/search.js.map +2 -2
  79. package/dist/session-kanban.d.ts +9 -0
  80. package/dist/session-kanban.d.ts.map +1 -1
  81. package/dist/session-kanban.js +137 -0
  82. package/dist/session-kanban.js.map +2 -2
  83. package/dist/task.js +4 -0
  84. package/dist/task.js.map +2 -2
  85. package/dist/test.js +61 -14
  86. package/dist/test.js.map +3 -3
  87. package/dist/todo.js +4 -0
  88. package/dist/todo.js.map +2 -2
  89. package/dist/tree.js +2 -2
  90. package/dist/tree.js.map +1 -1
  91. package/dist/typecheck.js +61 -14
  92. package/dist/typecheck.js.map +3 -3
  93. package/package.json +7 -3
package/dist/fetch.js CHANGED
@@ -57,7 +57,7 @@ function guardedLookup(hostname, options, callback) {
57
57
  var pinnedAgent;
58
58
  function getPinnedDispatcher() {
59
59
  if (!pinnedAgent) {
60
- pinnedAgent = new Agent({ connect: { lookup: guardedLookup } });
60
+ pinnedAgent = new Agent({ allowH2: false, connect: { lookup: guardedLookup } });
61
61
  }
62
62
  return pinnedAgent;
63
63
  }
@@ -159,7 +159,7 @@ async function assertNotPrivate(hostname) {
159
159
  }
160
160
  }
161
161
  } catch (err) {
162
- if (err instanceof Error && err.message.startsWith("fetch:")) throw err;
162
+ if (err instanceof ToolValidationError) throw err;
163
163
  }
164
164
  }
165
165
  }
package/dist/fetch.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/fetch.ts", "../src/_fetch-guard.ts", "../src/_util.ts"],
4
- "sourcesContent": ["import type { Tool, ToolStreamEvent } from '@wrongstack/core';\nimport { FetchError, ToolError, ToolValidationError } from '@wrongstack/core';\nimport TurndownService from 'turndown';\nimport { ALLOW_PRIVATE, assertNotPrivate, guardedFetch } from './_fetch-guard.js';\nimport { truncateMiddle } from './_util.js';\n\n/**\n * Singleton Turndown instance for HTML\u2192Markdown conversion.\n * Pre-configured with sensible defaults; code blocks are handled via the\n * default fenced code rule. Reused across all fetch calls.\n */\nconst TD = new TurndownService({\n // Use `# Title` for headings, not setext underline style (`Title\\n=====`).\n headingStyle: 'atx',\n // Don't wrap code blocks in <pre> \u2014 render them as triple-backtick blocks.\n codeBlockStyle: 'fenced',\n});\n\n// Strip <script>/<style>/<noscript> before turndown sees them. The old\n// hand-rolled converter did this via regex; turndown's DOM-based approach\n// may keep their text content unless we remove the elements first.\n// Using turndown's own addRule mechanism keeps the logic co-located.\nTD.addRule('stripDangerousElements', {\n filter: ['script', 'style', 'noscript'],\n replacement: () => '',\n});\n\ninterface FetchInput {\n url: string;\n format?: 'markdown' | 'text' | 'raw' | undefined;\n}\n\ninterface FetchOutput {\n content: string;\n status: number;\n content_type: string;\n url: string;\n}\n\nconst MAX_BYTES = 131_072;\nconst TIMEOUT_MS = 20_000;\n\n/** Abort when any of the signals abort (Node 22+ \u2014 AbortSignal.any shipped in Node 20). */\nconst combineSignals = (signals: AbortSignal[]): AbortSignal => AbortSignal.any(signals);\n\n// SSRF guard machinery (guardedLookup, the pinned undici dispatcher,\n// guardedFetch, assertNotPrivate) lives in _fetch-guard.ts so the `search`\n// entry can use guardedFetch without bundling the whole fetch tool.\n// Re-exported here to keep this module's historical public API intact.\nexport { guardedFetch, guardedLookup } from './_fetch-guard.js';\n\nexport const fetchTool: Tool<FetchInput, FetchOutput> = {\n name: 'fetch',\n category: 'Network',\n description:\n 'Fetch a URL and return its content. HTML pages are automatically converted to clean markdown. ' +\n 'This tool has strong SSRF protections (private IPs, localhost, and cloud metadata endpoints are blocked by default).',\n usageHint:\n 'Use this when you need external information (documentation, API responses, web pages, etc.).\\n\\n' +\n 'Security notes:\\n' +\n '- Only HTTPS is allowed by default.\\n' +\n '- Internal/private networks are blocked unless explicitly enabled via environment variable.\\n' +\n '- Redirects are followed but re-validated at each hop.\\n' +\n '- Output is capped (128KB by default) to avoid flooding context.\\n' +\n 'Prefer this over raw `bash curl` or `bash wget`.',\n permission: 'auto',\n mutating: false,\n capabilities: ['net.outbound'],\n icon: 'web',\n // Trust rules for fetch match on the literal URL \u2014 declare it explicitly\n // so a user can trust `https://api.example.com/*` without accidentally\n // matching that pattern on any other tool that happens to have a `url`\n // input field.\n subjectKey: 'url',\n timeoutMs: TIMEOUT_MS,\n maxOutputBytes: MAX_BYTES,\n inputSchema: {\n type: 'object',\n properties: {\n url: {\n type: 'string',\n description: 'The target URL (must use https://).',\n },\n format: {\n type: 'string',\n enum: ['markdown', 'text', 'raw'],\n description: 'Output format. \"markdown\" is recommended for HTML pages.',\n },\n },\n required: ['url'],\n },\n async execute(input, ctx, opts) {\n let final: FetchOutput | undefined;\n const executeStream = fetchTool.executeStream;\n if (!executeStream) {\n throw new ToolError({\n message: 'fetchTool: stream execution unavailable',\n code: 'TOOL_EXECUTION_FAILED',\n toolName: 'fetch',\n });\n }\n for await (const ev of executeStream(input, ctx, opts)) {\n if (ev.type === 'final') final = ev.output;\n }\n if (!final) {\n throw new ToolError({\n message: 'fetch: stream ended without final event',\n code: 'TOOL_EXECUTION_FAILED',\n toolName: 'fetch',\n });\n }\n return final;\n },\n async *executeStream(input, ctx, opts): AsyncGenerator<ToolStreamEvent<FetchOutput>> {\n if (!input?.url) {\n throw new ToolValidationError({\n message: 'fetch: url is required',\n field: 'url',\n });\n }\n const u = new URL(input.url);\n if (u.protocol !== 'https:' && u.protocol !== 'http:') {\n throw new ToolValidationError({\n message: `fetch: unsupported protocol \"${u.protocol}\"`,\n field: 'url',\n });\n }\n if (u.protocol === 'http:' && !ALLOW_PRIVATE) {\n throw new ToolValidationError({\n message: 'fetch: http:// blocked (HTTPS required by default)',\n field: 'url',\n });\n }\n await assertNotPrivate(u.hostname);\n\n yield { type: 'log', text: `GET ${input.url}` };\n\n const ctrl = new AbortController();\n const timer = setTimeout(() => ctrl.abort(new ToolError({\n message: 'fetch timeout',\n code: 'TOOL_TIMEOUT',\n toolName: 'fetch',\n })), TIMEOUT_MS);\n const combined = combineSignals([opts.signal, ctrl.signal]);\n\n try {\n let res: Response;\n try {\n res = await guardedFetch(input.url, 5, combined);\n } catch (err) {\n // A user-initiated cancel propagates unchanged. Our own timeout and any\n // transport failure get a diagnostic message: undici throws an opaque\n // `TypeError: fetch failed` whose real reason (ENOTFOUND, ECONNREFUSED,\n // UND_ERR_CONNECT_TIMEOUT, a TLS/cert error, \u2026) lives only on `.cause`.\n // Surfacing just `.message` left users with \"fetch failed\" and no clue\n // why HTTPS broke (see #100), so unwrap the cause chain here.\n if (opts.signal.aborted) throw err;\n throw describeFetchError(err, input.url, ctrl.signal.aborted);\n }\n\n const ct = res.headers.get('content-type') ?? 'application/octet-stream';\n if (/^image\\/|^audio\\/|^video\\/|application\\/octet-stream/.test(ct)) {\n throw new FetchError({\n message: `fetch: refusing to read binary content-type \"${ct}\"`,\n status: res.status,\n context: { url: res.url, contentType: ct },\n });\n }\n\n yield {\n type: 'log',\n text: `HTTP ${res.status} ${ct}`,\n data: { status: res.status, contentType: ct },\n };\n\n const reader = res.body?.getReader();\n let received = 0;\n const chunks: Uint8Array[] = [];\n let pendingBytes = 0;\n const FLUSH_AT = 4 * 1024;\n if (reader) {\n for (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n if (!value) continue;\n received += value.byteLength;\n pendingBytes += value.byteLength;\n chunks.push(value);\n if (pendingBytes >= FLUSH_AT) {\n // Snapshot recent bytes for the partial_output. Keep it cheap \u2014\n // don't try to decode UTF-8 boundaries; the TUI just needs a\n // \"things are happening\" signal.\n const recent = Buffer.from(value).toString('utf-8');\n yield {\n type: 'partial_output',\n text: recent,\n data: { received },\n };\n pendingBytes = 0;\n }\n if (received > MAX_BYTES) break;\n }\n }\n const text = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');\n\n const format = input.format ?? (ct.includes('text/html') ? 'markdown' : 'text');\n let content: string;\n if (format === 'raw') content = text;\n else if (format === 'markdown' && ct.includes('text/html')) content = TD.turndown(text);\n else if (ct.includes('application/json')) content = prettyJson(text);\n else content = text;\n\n yield {\n type: 'final',\n output: {\n content: truncateMiddle(content, MAX_BYTES),\n status: res.status,\n content_type: ct,\n url: res.url,\n },\n };\n // P2 #5: record the network request as a structured side effect.\n ctx.recordSideEffect?.({\n toolUseId: `fetch-${Date.now()}`,\n toolName: 'fetch',\n ts: new Date().toISOString(),\n input: { url: input.url, format: input.format },\n outcome: `HTTP ${res.status} (${ct})`,\n risk: 'network',\n });\n } finally {\n clearTimeout(timer);\n }\n },\n};\n\n/**\n * Turn an opaque undici `TypeError: fetch failed` into an actionable message by\n * walking its `.cause` chain. undici buries the transport reason (a DNS/socket\n * errno or a TLS handshake failure) one or more `.cause` hops down, so the bare\n * `.message` is always just \"fetch failed\". We join each distinct\n * `code: message` link so the user sees, e.g.,\n * `fetch: GET https://x failed \u2014 UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error`.\n */\nfunction describeFetchError(err: unknown, url: string, timedOut: boolean): FetchError | ToolError {\n if (timedOut) {\n return new ToolError({\n message: `fetch: GET ${url} timed out after ${TIMEOUT_MS}ms`,\n code: 'TOOL_TIMEOUT',\n toolName: 'fetch',\n context: { url, timedOut: true, timeoutMs: TIMEOUT_MS },\n cause: err,\n });\n }\n const parts: string[] = [];\n const seen = new Set<unknown>();\n let cur: unknown = err;\n while (cur instanceof Error && !seen.has(cur)) {\n seen.add(cur);\n const code = (cur as NodeJS.ErrnoException).code;\n const label = code ? `${code}: ${cur.message}` : cur.message;\n // Skip undici's uninformative top-level \"fetch failed\" wrapper, but keep it\n // as a fallback if it turns out to be the only thing we have.\n if (label && label !== 'fetch failed' && !parts.includes(label)) parts.push(label);\n cur = (cur as { cause?: unknown }).cause;\n }\n const detail = parts.length > 0 ? parts.join(' \u2192 ') : 'fetch failed';\n return new FetchError({\n message: `fetch: GET ${url} failed \u2014 ${detail}`,\n status: 502,\n context: { url, timedOut: false, transportErrors: parts },\n // Preserve the original undici / DNS / TLS chain so callers can inspect\n // it via `err.cause` and structured `instanceof` checks. The flattened\n // text version stays in the message for human readability.\n cause: err,\n });\n}\n\nfunction prettyJson(s: string): string {\n try {\n return JSON.stringify(JSON.parse(s), null, 2);\n } catch {\n return s;\n }\n}\n", "import * as dns from 'node:dns/promises';\nimport * as net from 'node:net';\nimport {\n FetchError,\n ToolValidationError,\n isPrivateIPv4,\n isPrivateIPv6,\n} from '@wrongstack/core';\nimport { Agent, fetch as undiciFetch } from 'undici';\n\n/**\n * SSRF guard machinery shared by the `fetch` and `search` tools. Split out of\n * fetch.ts so entries that only need `guardedFetch` (search) don't bundle the\n * whole fetch tool. Everything here is security-critical: the pinned undici\n * dispatcher performs the SINGLE DNS resolution the TCP connection uses, so\n * there is no DNS-rebinding TOCTOU between validation and connect.\n */\n\nconst nativeGlobalFetch = globalThis.fetch;\n\nexport const ALLOW_PRIVATE = process.env['WRONGSTACK_FETCH_ALLOW_PRIVATE'] === '1';\n/* v8 ignore next 8 -- module-load-time opt-in warning; gated on an env var not set during tests. */\nif (ALLOW_PRIVATE && !process.env['CI']) {\n console.warn(\n '[WrongStack] WARNING: WRONGSTACK_FETCH_ALLOW_PRIVATE=1 is active \u2014\\n' +\n ' fetch tool can now access private IPs (10.x, 192.168.x, 169.254.x),\\n' +\n ' cloud metadata endpoints, and plaintext HTTP. Use only on isolated networks.',\n );\n}\n\ntype LookupCallback = (\n err: NodeJS.ErrnoException | null,\n address?: string | Array<{ address: string | undefined; family: number }>,\n family?: number | undefined,\n) => void;\n\n/**\n * DNS lookup used by the undici dispatcher below. It performs the SINGLE name\n * resolution that the TCP connection actually uses, and rejects if any\n * resolved address is private/loopback/link-local. Because the connection\n * reuses exactly this result, there is no DNS-rebinding TOCTOU window between\n * the security check and the connect \u2014 closing the gap the old code documented\n * (validate with one dns.lookup, then let fetch re-resolve independently).\n * TLS still validates the certificate against the hostname (SNI is set by\n * undici from the URL), so pinning the IP does not weaken cert checking.\n */\nexport function guardedLookup(\n hostname: string,\n options: { all?: boolean | undefined; family?: number | undefined },\n callback: LookupCallback,\n): void {\n dns\n .lookup(hostname, { all: true })\n .then((records) => {\n const family = options?.family;\n const byFamily =\n family === 4 || family === 6 ? records.filter((r) => r.family === family) : records;\n const list = byFamily.length > 0 ? byFamily : records;\n if (!ALLOW_PRIVATE) {\n for (const r of list) {\n const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);\n if (bad) {\n callback(\n Object.assign(new Error(`fetch: resolved to private address ${r.address}`), {\n code: 'EAI_FAIL',\n }),\n );\n return;\n }\n }\n }\n if (options?.all) {\n callback(\n null,\n list.map((r) => ({ address: r.address, family: r.family })),\n );\n return;\n }\n const first = list.at(0);\n if (!first) {\n callback(\n Object.assign(new Error(`fetch: no address for ${hostname}`), { code: 'ENOTFOUND' }),\n );\n return;\n }\n callback(null, first.address, first.family);\n })\n .catch((err) => callback(err as NodeJS.ErrnoException));\n}\n\n// Reused across requests; guardedLookup re-validates on every new connection,\n// so connection pooling is safe. Literal-IP targets bypass lookup entirely and\n// are caught by assertNotPrivate's pre-check instead.\n// Destroyed on process exit so long-running processes (eternal autonomy,\n// MCP server mode) don't let the connection pool grow unboundedly.\nlet pinnedAgent: Agent | undefined;\nfunction getPinnedDispatcher(): Agent {\n if (!pinnedAgent) {\n pinnedAgent = new Agent({ connect: { lookup: guardedLookup as never } });\n }\n return pinnedAgent;\n}\n\nfunction dispatcherFetch(): typeof globalThis.fetch {\n // Node's built-in global fetch is backed by its own bundled undici version.\n // Passing an Agent from the workspace's `undici` package to that different\n // dispatcher ABI fails on recent Node with:\n // UND_ERR_INVALID_ARG: invalid onRequestStart method\n // Use the matching package fetch+Agent pair in real runs, but keep honoring\n // test/user fetch shims that replace globalThis.fetch after this module loads.\n return globalThis.fetch === nativeGlobalFetch\n ? (undiciFetch as unknown as typeof globalThis.fetch)\n : globalThis.fetch;\n}\n// Clean up the global dispatcher on exit \u2014 undici Agents maintain connection\n// pools and DNS caches that should be torn down in long-running processes.\n// Guard against duplicate registration (module reload/HMR would otherwise\n// accumulate listeners).\nlet _beforeExitRegistered = false;\nif (!_beforeExitRegistered) {\n _beforeExitRegistered = true;\n /* v8 ignore next 4 -- process 'beforeExit' cleanup; not deterministically triggerable in-test. */\n process.on('beforeExit', () => {\n pinnedAgent?.destroy();\n pinnedAgent = undefined;\n });\n}\n\n/**\n * SSRF-guarded fetch with manual, per-hop-revalidated redirects, exported so\n * other builtin tools (e.g. `search`) get the same protections instead of a\n * weaker `redirect: 'follow'`. Every hop is re-checked against private/loopback\n * ranges and the connection is pinned to the validated IP via the undici\n * dispatcher (no DNS-rebinding TOCTOU). `headers` defaults to the plain `fetch`\n * tool's; callers may override (e.g. a browser User-Agent for search engines).\n */\nexport async function guardedFetch(\n url: string,\n maxRedirects: number,\n signal: AbortSignal,\n headers: Record<string, string> = {\n 'user-agent': 'WrongStack/1.0 (+https://wrongstack.com)',\n accept: 'text/html,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.1',\n },\n): Promise<Response> {\n let redirectCount = 0;\n let currentUrl = url;\n for (;;) {\n // Re-validate every hop. A public host can 302 to 169.254.169.254 (cloud metadata),\n // or DNS can rebind between hops; checking only the initial URL is insufficient.\n const parsed = new URL(currentUrl);\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {\n throw new ToolValidationError({\n message: `fetch: redirect to unsupported protocol \"${parsed.protocol}\"`,\n field: 'url',\n });\n }\n if (parsed.protocol === 'http:' && !ALLOW_PRIVATE) {\n throw new ToolValidationError({\n message: 'fetch: redirect to http:// blocked (HTTPS required by default)',\n field: 'url',\n });\n }\n await assertNotPrivate(parsed.hostname);\n\n // The dispatcher pins the connection to the IP guardedLookup validated \u2014\n // no independent re-resolution, so DNS rebinding can't swap in a private\n // address between check and connect. `dispatcher` is a runtime option of\n // Node's undici-backed global fetch but isn't in lib.dom's RequestInit, and\n // our undici Agent's type differs from the @types/node copy \u2014 hence the\n // cast. (Verified: global fetch invokes the Agent's custom lookup.)\n const init = {\n redirect: 'manual' as const,\n signal,\n headers,\n dispatcher: getPinnedDispatcher(),\n };\n const res = await dispatcherFetch()(currentUrl, init as never as RequestInit);\n if (res.status < 300 || res.status > 399) {\n return res;\n }\n redirectCount++;\n if (redirectCount > maxRedirects) {\n throw new FetchError({\n message: `fetch: exceeded ${maxRedirects} redirects`,\n status: res.status,\n context: { url: currentUrl, maxRedirects, redirectCount },\n });\n }\n const location = res.headers.get('location');\n if (!location) {\n throw new FetchError({\n message: 'fetch: redirect status with no location header',\n status: res.status,\n context: { url: currentUrl, redirectCount },\n });\n }\n currentUrl = new URL(location, currentUrl).toString();\n }\n}\n\nexport async function assertNotPrivate(hostname: string): Promise<void> {\n if (ALLOW_PRIVATE) return;\n\n const host =\n hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;\n\n if (host === 'localhost' || host.endsWith('.localhost')) {\n throw new ToolValidationError({\n message: 'fetch: blocked localhost target',\n field: 'url',\n });\n }\n\n const ipVersion = net.isIP(host);\n if (ipVersion === 4) {\n if (isPrivateIPv4(host)) {\n throw new ToolValidationError({\n message: `fetch: blocked private/loopback address \"${host}\"`,\n field: 'url',\n });\n }\n } else if (ipVersion === 6) {\n if (isPrivateIPv6(host)) {\n throw new ToolValidationError({\n message: `fetch: blocked private/loopback address \"${host}\"`,\n field: 'url',\n });\n }\n } else {\n // Hostname \u2014 pre-flight check: resolve and reject if any record is private,\n // so we fail fast with a clear error before opening a socket. The\n // authoritative anti-rebinding control is guardedLookup on the pinned\n // undici dispatcher (see getPinnedDispatcher): it performs the single\n // resolution the connection actually uses, so there is no TOCTOU between\n // this check and the connect. Each redirect target is re-checked too.\n try {\n // Use dns.lookup for async hostname resolution (matches guardedLookup above).\n const records = await dns.lookup(host, { all: true });\n for (const r of records) {\n const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);\n if (bad) {\n throw new ToolValidationError({\n message: `fetch: resolved to private address ${r.address}`,\n field: 'url',\n });\n }\n }\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('fetch:')) throw err;\n // DNS failure \u2014 let fetch handle it\n }\n }\n}\n", "import { createHash } from 'node:crypto';\nimport * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n\n/**\n * sha-256 hex of a UTF-8 string. Used by the file tools to record a content\n * hash alongside the mtime in `ctx.recordRead` \u2014 the hash is the authoritative\n * staleness arbiter for `edit` (mtime has a 2 s tolerance window on Windows).\n */\nexport function sha256hex(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex');\n}\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm \u2192 yarn \u2192 npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root\u2192out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink \u2014 macOS `/var`\u2192`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n\u2026[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]\u2026\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// \u2500\u2500\u2500 Command-output normalization (token-saving) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) \u2014 never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only \u2014 it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `\u2026 \u27E8repeated ${run}\u00D7\u27E9`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends \u2014 the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n\u2026[truncated ${total - kept} bytes]\u2026\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI \u2192 collapse\n * carriage-return progress \u2192 trim trailing whitespace \u2192 collapse identical\n * consecutive lines \u2192 squeeze blank-line runs \u2192 head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines \u2192 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { Tool, ToolStreamEvent } from '@wrongstack/core';\nimport { FetchError, ToolError, ToolValidationError } from '@wrongstack/core';\nimport TurndownService from 'turndown';\nimport { ALLOW_PRIVATE, assertNotPrivate, guardedFetch } from './_fetch-guard.js';\nimport { truncateMiddle } from './_util.js';\n\n/**\n * Singleton Turndown instance for HTML\u2192Markdown conversion.\n * Pre-configured with sensible defaults; code blocks are handled via the\n * default fenced code rule. Reused across all fetch calls.\n */\nconst TD = new TurndownService({\n // Use `# Title` for headings, not setext underline style (`Title\\n=====`).\n headingStyle: 'atx',\n // Don't wrap code blocks in <pre> \u2014 render them as triple-backtick blocks.\n codeBlockStyle: 'fenced',\n});\n\n// Strip <script>/<style>/<noscript> before turndown sees them. The old\n// hand-rolled converter did this via regex; turndown's DOM-based approach\n// may keep their text content unless we remove the elements first.\n// Using turndown's own addRule mechanism keeps the logic co-located.\nTD.addRule('stripDangerousElements', {\n filter: ['script', 'style', 'noscript'],\n replacement: () => '',\n});\n\ninterface FetchInput {\n url: string;\n format?: 'markdown' | 'text' | 'raw' | undefined;\n}\n\ninterface FetchOutput {\n content: string;\n status: number;\n content_type: string;\n url: string;\n}\n\nconst MAX_BYTES = 131_072;\nconst TIMEOUT_MS = 20_000;\n\n/** Abort when any of the signals abort (Node 22+ \u2014 AbortSignal.any shipped in Node 20). */\nconst combineSignals = (signals: AbortSignal[]): AbortSignal => AbortSignal.any(signals);\n\n// SSRF guard machinery (guardedLookup, the pinned undici dispatcher,\n// guardedFetch, assertNotPrivate) lives in _fetch-guard.ts so the `search`\n// entry can use guardedFetch without bundling the whole fetch tool.\n// Re-exported here to keep this module's historical public API intact.\nexport { guardedFetch, guardedLookup } from './_fetch-guard.js';\n\nexport const fetchTool: Tool<FetchInput, FetchOutput> = {\n name: 'fetch',\n category: 'Network',\n description:\n 'Fetch a URL and return its content. HTML pages are automatically converted to clean markdown. ' +\n 'This tool has strong SSRF protections (private IPs, localhost, and cloud metadata endpoints are blocked by default).',\n usageHint:\n 'Use this when you need external information (documentation, API responses, web pages, etc.).\\n\\n' +\n 'Security notes:\\n' +\n '- Only HTTPS is allowed by default.\\n' +\n '- Internal/private networks are blocked unless explicitly enabled via environment variable.\\n' +\n '- Redirects are followed but re-validated at each hop.\\n' +\n '- Output is capped (128KB by default) to avoid flooding context.\\n' +\n 'Prefer this over raw `bash curl` or `bash wget`.',\n permission: 'auto',\n mutating: false,\n capabilities: ['net.outbound'],\n icon: 'web',\n // Trust rules for fetch match on the literal URL \u2014 declare it explicitly\n // so a user can trust `https://api.example.com/*` without accidentally\n // matching that pattern on any other tool that happens to have a `url`\n // input field.\n subjectKey: 'url',\n timeoutMs: TIMEOUT_MS,\n maxOutputBytes: MAX_BYTES,\n inputSchema: {\n type: 'object',\n properties: {\n url: {\n type: 'string',\n description: 'The target URL (must use https://).',\n },\n format: {\n type: 'string',\n enum: ['markdown', 'text', 'raw'],\n description: 'Output format. \"markdown\" is recommended for HTML pages.',\n },\n },\n required: ['url'],\n },\n async execute(input, ctx, opts) {\n let final: FetchOutput | undefined;\n const executeStream = fetchTool.executeStream;\n if (!executeStream) {\n throw new ToolError({\n message: 'fetchTool: stream execution unavailable',\n code: 'TOOL_EXECUTION_FAILED',\n toolName: 'fetch',\n });\n }\n for await (const ev of executeStream(input, ctx, opts)) {\n if (ev.type === 'final') final = ev.output;\n }\n if (!final) {\n throw new ToolError({\n message: 'fetch: stream ended without final event',\n code: 'TOOL_EXECUTION_FAILED',\n toolName: 'fetch',\n });\n }\n return final;\n },\n async *executeStream(input, ctx, opts): AsyncGenerator<ToolStreamEvent<FetchOutput>> {\n if (!input?.url) {\n throw new ToolValidationError({\n message: 'fetch: url is required',\n field: 'url',\n });\n }\n const u = new URL(input.url);\n if (u.protocol !== 'https:' && u.protocol !== 'http:') {\n throw new ToolValidationError({\n message: `fetch: unsupported protocol \"${u.protocol}\"`,\n field: 'url',\n });\n }\n if (u.protocol === 'http:' && !ALLOW_PRIVATE) {\n throw new ToolValidationError({\n message: 'fetch: http:// blocked (HTTPS required by default)',\n field: 'url',\n });\n }\n await assertNotPrivate(u.hostname);\n\n yield { type: 'log', text: `GET ${input.url}` };\n\n const ctrl = new AbortController();\n const timer = setTimeout(() => ctrl.abort(new ToolError({\n message: 'fetch timeout',\n code: 'TOOL_TIMEOUT',\n toolName: 'fetch',\n })), TIMEOUT_MS);\n const combined = combineSignals([opts.signal, ctrl.signal]);\n\n try {\n let res: Response;\n try {\n res = await guardedFetch(input.url, 5, combined);\n } catch (err) {\n // A user-initiated cancel propagates unchanged. Our own timeout and any\n // transport failure get a diagnostic message: undici throws an opaque\n // `TypeError: fetch failed` whose real reason (ENOTFOUND, ECONNREFUSED,\n // UND_ERR_CONNECT_TIMEOUT, a TLS/cert error, \u2026) lives only on `.cause`.\n // Surfacing just `.message` left users with \"fetch failed\" and no clue\n // why HTTPS broke (see #100), so unwrap the cause chain here.\n if (opts.signal.aborted) throw err;\n throw describeFetchError(err, input.url, ctrl.signal.aborted);\n }\n\n const ct = res.headers.get('content-type') ?? 'application/octet-stream';\n if (/^image\\/|^audio\\/|^video\\/|application\\/octet-stream/.test(ct)) {\n throw new FetchError({\n message: `fetch: refusing to read binary content-type \"${ct}\"`,\n status: res.status,\n context: { url: res.url, contentType: ct },\n });\n }\n\n yield {\n type: 'log',\n text: `HTTP ${res.status} ${ct}`,\n data: { status: res.status, contentType: ct },\n };\n\n const reader = res.body?.getReader();\n let received = 0;\n const chunks: Uint8Array[] = [];\n let pendingBytes = 0;\n const FLUSH_AT = 4 * 1024;\n if (reader) {\n for (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n if (!value) continue;\n received += value.byteLength;\n pendingBytes += value.byteLength;\n chunks.push(value);\n if (pendingBytes >= FLUSH_AT) {\n // Snapshot recent bytes for the partial_output. Keep it cheap \u2014\n // don't try to decode UTF-8 boundaries; the TUI just needs a\n // \"things are happening\" signal.\n const recent = Buffer.from(value).toString('utf-8');\n yield {\n type: 'partial_output',\n text: recent,\n data: { received },\n };\n pendingBytes = 0;\n }\n if (received > MAX_BYTES) break;\n }\n }\n const text = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');\n\n const format = input.format ?? (ct.includes('text/html') ? 'markdown' : 'text');\n let content: string;\n if (format === 'raw') content = text;\n else if (format === 'markdown' && ct.includes('text/html')) content = TD.turndown(text);\n else if (ct.includes('application/json')) content = prettyJson(text);\n else content = text;\n\n yield {\n type: 'final',\n output: {\n content: truncateMiddle(content, MAX_BYTES),\n status: res.status,\n content_type: ct,\n url: res.url,\n },\n };\n // P2 #5: record the network request as a structured side effect.\n ctx.recordSideEffect?.({\n toolUseId: `fetch-${Date.now()}`,\n toolName: 'fetch',\n ts: new Date().toISOString(),\n input: { url: input.url, format: input.format },\n outcome: `HTTP ${res.status} (${ct})`,\n risk: 'network',\n });\n } finally {\n clearTimeout(timer);\n }\n },\n};\n\n/**\n * Turn an opaque undici `TypeError: fetch failed` into an actionable message by\n * walking its `.cause` chain. undici buries the transport reason (a DNS/socket\n * errno or a TLS handshake failure) one or more `.cause` hops down, so the bare\n * `.message` is always just \"fetch failed\". We join each distinct\n * `code: message` link so the user sees, e.g.,\n * `fetch: GET https://x failed \u2014 UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error`.\n */\nfunction describeFetchError(err: unknown, url: string, timedOut: boolean): FetchError | ToolError {\n if (timedOut) {\n return new ToolError({\n message: `fetch: GET ${url} timed out after ${TIMEOUT_MS}ms`,\n code: 'TOOL_TIMEOUT',\n toolName: 'fetch',\n context: { url, timedOut: true, timeoutMs: TIMEOUT_MS },\n cause: err,\n });\n }\n const parts: string[] = [];\n const seen = new Set<unknown>();\n let cur: unknown = err;\n while (cur instanceof Error && !seen.has(cur)) {\n seen.add(cur);\n const code = (cur as NodeJS.ErrnoException).code;\n const label = code ? `${code}: ${cur.message}` : cur.message;\n // Skip undici's uninformative top-level \"fetch failed\" wrapper, but keep it\n // as a fallback if it turns out to be the only thing we have.\n if (label && label !== 'fetch failed' && !parts.includes(label)) parts.push(label);\n cur = (cur as { cause?: unknown }).cause;\n }\n const detail = parts.length > 0 ? parts.join(' \u2192 ') : 'fetch failed';\n return new FetchError({\n message: `fetch: GET ${url} failed \u2014 ${detail}`,\n status: 502,\n context: { url, timedOut: false, transportErrors: parts },\n // Preserve the original undici / DNS / TLS chain so callers can inspect\n // it via `err.cause` and structured `instanceof` checks. The flattened\n // text version stays in the message for human readability.\n cause: err,\n });\n}\n\nfunction prettyJson(s: string): string {\n try {\n return JSON.stringify(JSON.parse(s), null, 2);\n } catch {\n return s;\n }\n}\n", "import * as dns from 'node:dns/promises';\nimport * as net from 'node:net';\nimport {\n FetchError,\n ToolValidationError,\n isPrivateIPv4,\n isPrivateIPv6,\n} from '@wrongstack/core';\nimport { Agent, fetch as undiciFetch } from 'undici';\n\n/**\n * SSRF guard machinery shared by the `fetch` and `search` tools. Split out of\n * fetch.ts so entries that only need `guardedFetch` (search) don't bundle the\n * whole fetch tool. Everything here is security-critical: the pinned undici\n * dispatcher performs the SINGLE DNS resolution the TCP connection uses, so\n * there is no DNS-rebinding TOCTOU between validation and connect.\n */\n\nconst nativeGlobalFetch = globalThis.fetch;\n\nexport const ALLOW_PRIVATE = process.env['WRONGSTACK_FETCH_ALLOW_PRIVATE'] === '1';\n/* v8 ignore next 8 -- module-load-time opt-in warning; gated on an env var not set during tests. */\nif (ALLOW_PRIVATE && !process.env['CI']) {\n console.warn(\n '[WrongStack] WARNING: WRONGSTACK_FETCH_ALLOW_PRIVATE=1 is active \u2014\\n' +\n ' fetch tool can now access private IPs (10.x, 192.168.x, 169.254.x),\\n' +\n ' cloud metadata endpoints, and plaintext HTTP. Use only on isolated networks.',\n );\n}\n\ntype LookupCallback = (\n err: NodeJS.ErrnoException | null,\n address?: string | Array<{ address: string | undefined; family: number }>,\n family?: number | undefined,\n) => void;\n\n/**\n * DNS lookup used by the undici dispatcher below. It performs the SINGLE name\n * resolution that the TCP connection actually uses, and rejects if any\n * resolved address is private/loopback/link-local. Because the connection\n * reuses exactly this result, there is no DNS-rebinding TOCTOU window between\n * the security check and the connect \u2014 closing the gap the old code documented\n * (validate with one dns.lookup, then let fetch re-resolve independently).\n * TLS still validates the certificate against the hostname (SNI is set by\n * undici from the URL), so pinning the IP does not weaken cert checking.\n */\nexport function guardedLookup(\n hostname: string,\n options: { all?: boolean | undefined; family?: number | undefined },\n callback: LookupCallback,\n): void {\n dns\n .lookup(hostname, { all: true })\n .then((records) => {\n const family = options?.family;\n const byFamily =\n family === 4 || family === 6 ? records.filter((r) => r.family === family) : records;\n const list = byFamily.length > 0 ? byFamily : records;\n if (!ALLOW_PRIVATE) {\n for (const r of list) {\n const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);\n if (bad) {\n callback(\n Object.assign(new Error(`fetch: resolved to private address ${r.address}`), {\n code: 'EAI_FAIL',\n }),\n );\n return;\n }\n }\n }\n if (options?.all) {\n callback(\n null,\n list.map((r) => ({ address: r.address, family: r.family })),\n );\n return;\n }\n const first = list.at(0);\n if (!first) {\n callback(\n Object.assign(new Error(`fetch: no address for ${hostname}`), { code: 'ENOTFOUND' }),\n );\n return;\n }\n callback(null, first.address, first.family);\n })\n .catch((err) => callback(err as NodeJS.ErrnoException));\n}\n\n// Reused across requests; guardedLookup re-validates on every new connection,\n// so connection pooling is safe. Literal-IP targets bypass lookup entirely and\n// are caught by assertNotPrivate's pre-check instead.\n// Destroyed on process exit so long-running processes (eternal autonomy,\n// MCP server mode) don't let the connection pool grow unboundedly.\nlet pinnedAgent: Agent | undefined;\nfunction getPinnedDispatcher(): Agent {\n if (!pinnedAgent) {\n // Undici 8 enables HTTP/2 negotiation by default. Its H2 stream can emit a\n // late, unhandled `error` after fetch already rejected when the peer closes\n // the TLS socket, terminating the whole WrongStack process. The fetch tool\n // does not need multiplexing, so retain the proven HTTP/1.1 transport while\n // preserving the pinned DNS lookup and connection pooling.\n pinnedAgent = new Agent({ allowH2: false, connect: { lookup: guardedLookup as never } });\n }\n return pinnedAgent;\n}\n\nfunction dispatcherFetch(): typeof globalThis.fetch {\n // Node's built-in global fetch is backed by its own bundled undici version.\n // Passing an Agent from the workspace's `undici` package to that different\n // dispatcher ABI fails on recent Node with:\n // UND_ERR_INVALID_ARG: invalid onRequestStart method\n // Use the matching package fetch+Agent pair in real runs, but keep honoring\n // test/user fetch shims that replace globalThis.fetch after this module loads.\n return globalThis.fetch === nativeGlobalFetch\n ? (undiciFetch as unknown as typeof globalThis.fetch)\n : globalThis.fetch;\n}\n// Clean up the global dispatcher on exit \u2014 undici Agents maintain connection\n// pools and DNS caches that should be torn down in long-running processes.\n// Guard against duplicate registration (module reload/HMR would otherwise\n// accumulate listeners).\nlet _beforeExitRegistered = false;\nif (!_beforeExitRegistered) {\n _beforeExitRegistered = true;\n /* v8 ignore next 4 -- process 'beforeExit' cleanup; not deterministically triggerable in-test. */\n process.on('beforeExit', () => {\n pinnedAgent?.destroy();\n pinnedAgent = undefined;\n });\n}\n\n/**\n * SSRF-guarded fetch with manual, per-hop-revalidated redirects, exported so\n * other builtin tools (e.g. `search`) get the same protections instead of a\n * weaker `redirect: 'follow'`. Every hop is re-checked against private/loopback\n * ranges and the connection is pinned to the validated IP via the undici\n * dispatcher (no DNS-rebinding TOCTOU). `headers` defaults to the plain `fetch`\n * tool's; callers may override (e.g. a browser User-Agent for search engines).\n */\nexport async function guardedFetch(\n url: string,\n maxRedirects: number,\n signal: AbortSignal,\n headers: Record<string, string> = {\n 'user-agent': 'WrongStack/1.0 (+https://wrongstack.com)',\n accept: 'text/html,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.1',\n },\n): Promise<Response> {\n let redirectCount = 0;\n let currentUrl = url;\n for (;;) {\n // Re-validate every hop. A public host can 302 to 169.254.169.254 (cloud metadata),\n // or DNS can rebind between hops; checking only the initial URL is insufficient.\n const parsed = new URL(currentUrl);\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {\n throw new ToolValidationError({\n message: `fetch: redirect to unsupported protocol \"${parsed.protocol}\"`,\n field: 'url',\n });\n }\n if (parsed.protocol === 'http:' && !ALLOW_PRIVATE) {\n throw new ToolValidationError({\n message: 'fetch: redirect to http:// blocked (HTTPS required by default)',\n field: 'url',\n });\n }\n await assertNotPrivate(parsed.hostname);\n\n // The dispatcher pins the connection to the IP guardedLookup validated \u2014\n // no independent re-resolution, so DNS rebinding can't swap in a private\n // address between check and connect. `dispatcher` is a runtime option of\n // Node's undici-backed global fetch but isn't in lib.dom's RequestInit, and\n // our undici Agent's type differs from the @types/node copy \u2014 hence the\n // cast. (Verified: global fetch invokes the Agent's custom lookup.)\n const init = {\n redirect: 'manual' as const,\n signal,\n headers,\n dispatcher: getPinnedDispatcher(),\n };\n const res = await dispatcherFetch()(currentUrl, init as never as RequestInit);\n if (res.status < 300 || res.status > 399) {\n return res;\n }\n redirectCount++;\n if (redirectCount > maxRedirects) {\n throw new FetchError({\n message: `fetch: exceeded ${maxRedirects} redirects`,\n status: res.status,\n context: { url: currentUrl, maxRedirects, redirectCount },\n });\n }\n const location = res.headers.get('location');\n if (!location) {\n throw new FetchError({\n message: 'fetch: redirect status with no location header',\n status: res.status,\n context: { url: currentUrl, redirectCount },\n });\n }\n currentUrl = new URL(location, currentUrl).toString();\n }\n}\n\nexport async function assertNotPrivate(hostname: string): Promise<void> {\n if (ALLOW_PRIVATE) return;\n\n const host =\n hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;\n\n if (host === 'localhost' || host.endsWith('.localhost')) {\n throw new ToolValidationError({\n message: 'fetch: blocked localhost target',\n field: 'url',\n });\n }\n\n const ipVersion = net.isIP(host);\n if (ipVersion === 4) {\n if (isPrivateIPv4(host)) {\n throw new ToolValidationError({\n message: `fetch: blocked private/loopback address \"${host}\"`,\n field: 'url',\n });\n }\n } else if (ipVersion === 6) {\n if (isPrivateIPv6(host)) {\n throw new ToolValidationError({\n message: `fetch: blocked private/loopback address \"${host}\"`,\n field: 'url',\n });\n }\n } else {\n // Hostname \u2014 pre-flight check: resolve and reject if any record is private,\n // so we fail fast with a clear error before opening a socket. The\n // authoritative anti-rebinding control is guardedLookup on the pinned\n // undici dispatcher (see getPinnedDispatcher): it performs the single\n // resolution the connection actually uses, so there is no TOCTOU between\n // this check and the connect. Each redirect target is re-checked too.\n try {\n // Use dns.lookup for async hostname resolution (matches guardedLookup above).\n const records = await dns.lookup(host, { all: true });\n for (const r of records) {\n const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);\n if (bad) {\n throw new ToolValidationError({\n message: `fetch: resolved to private address ${r.address}`,\n field: 'url',\n });\n }\n }\n } catch (err) {\n if (err instanceof ToolValidationError) throw err;\n // DNS failure \u2014 let fetch handle it\n }\n }\n}\n", "import { createHash } from 'node:crypto';\nimport * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n\n/**\n * sha-256 hex of a UTF-8 string. Used by the file tools to record a content\n * hash alongside the mtime in `ctx.recordRead` \u2014 the hash is the authoritative\n * staleness arbiter for `edit` (mtime has a 2 s tolerance window on Windows).\n */\nexport function sha256hex(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex');\n}\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm \u2192 yarn \u2192 npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root\u2192out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink \u2014 macOS `/var`\u2192`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n\u2026[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]\u2026\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// \u2500\u2500\u2500 Command-output normalization (token-saving) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) \u2014 never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only \u2014 it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `\u2026 \u27E8repeated ${run}\u00D7\u27E9`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends \u2014 the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n\u2026[truncated ${total - kept} bytes]\u2026\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI \u2192 collapse\n * carriage-return progress \u2192 trim trailing whitespace \u2192 collapse identical\n * consecutive lines \u2192 squeeze blank-line runs \u2192 head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines \u2192 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["FetchError", "ToolValidationError", "ToolValidationError", "FetchError"]
7
7
  }
package/dist/format.js CHANGED
@@ -1,6 +1,11 @@
1
1
  // src/_spawn-stream.ts
2
2
  import { spawn as spawn2 } from "node:child_process";
3
- import { buildChildEnv } from "@wrongstack/core";
3
+ import {
4
+ buildChildEnv,
5
+ emitProcessCompleted,
6
+ emitProcessOutput,
7
+ emitProcessStarted
8
+ } from "@wrongstack/core";
4
9
 
5
10
  // src/_output-spool.ts
6
11
  import { createWriteStream, mkdirSync } from "node:fs";
@@ -310,9 +315,14 @@ import { expectDefined } from "@wrongstack/core";
310
315
  var SENSITIVE_FLAG_PATTERNS = [
311
316
  // --flag=value or --flag "value" (value captured up to next space or comma)
312
317
  /--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\s,][^\s]*)?/gi,
313
- // -f "value" style short flags
314
- /(?<!\w)-t(?:\s+|\s*=\s*)[^\s,]+/,
315
- /(?<!\w)-(?:p|password)(?:\s+|\s*=\s*)[^\s,]+/gi,
318
+ // -t short flag (token): attached (-tVALUE), separated (-t VALUE), or -t=VALUE.
319
+ // (?<![-\w]) anchors to a token start so we don't match the `-t` inside `--token`.
320
+ // NOTE: synced with @wrongstack/core observability/redact-command.ts.
321
+ /(?<![-\w])-t(?:[=\s]+)?[^\s,-]+/,
322
+ // -p|-password|-a (redis auth) short flags: attached + separated + =value.
323
+ // Same token-start anchor; over-redaction is an accepted tradeoff for a
324
+ // redaction function. Synced with core copy.
325
+ /(?<![-\w])-(?:password|p|a)(?:[=\s]+)?[^\s,-]+/gi,
316
326
  // env var–style secrets: TOKEN=x, API_KEY=y, etc.
317
327
  /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\s*[=:]\s*[^\s,]+/gi,
318
328
  // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits — but only
@@ -330,8 +340,10 @@ function redactCommand(cmd) {
330
340
  const flag = match.slice(0, match.indexOf(expectDefined(delim)) + 1);
331
341
  return `${flag}[REDACTED]`;
332
342
  }
333
- const flagEnd = match.match(/^--?[a-zA-Z][a-zA-Z0-9_-]*/)?.[0] ?? match;
334
- return `${flagEnd}=**redacted**`;
343
+ if (match.startsWith("--")) {
344
+ return match;
345
+ }
346
+ return `${match.slice(0, 2)}[REDACTED]`;
335
347
  });
336
348
  }
337
349
  return result;
@@ -768,6 +780,19 @@ async function* spawnStream(opts) {
768
780
  });
769
781
  const registry = getProcessRegistry();
770
782
  const pid = child.pid;
783
+ const processStartedAt = Date.now();
784
+ let stdoutBytes = 0;
785
+ let stderrBytes = 0;
786
+ let telemetryCompleted = false;
787
+ emitProcessStarted({
788
+ ...pid !== void 0 ? { pid } : {},
789
+ parentPid: process.pid,
790
+ command: redactCommand(`${opts.cmd} ${opts.args.join(" ")}`),
791
+ args: redactCommand(opts.args.join(" ")).split(" ").filter(Boolean),
792
+ cwd: opts.cwd,
793
+ background: false,
794
+ startedAt: new Date(processStartedAt).toISOString()
795
+ });
771
796
  if (typeof pid === "number") {
772
797
  registry.register({
773
798
  pid,
@@ -796,6 +821,8 @@ async function* spawnStream(opts) {
796
821
  };
797
822
  const onOut = (c) => {
798
823
  const s = c.toString();
824
+ stdoutBytes += c.byteLength;
825
+ emitProcessOutput({ pid, stream: "stdout", chunk: c });
799
826
  if (stdout.length < max) stdout += s;
800
827
  spool.write(s);
801
828
  queue.push({ kind: "out", data: s });
@@ -808,6 +835,8 @@ async function* spawnStream(opts) {
808
835
  };
809
836
  const onErr = (c) => {
810
837
  const s = c.toString();
838
+ stderrBytes += c.byteLength;
839
+ emitProcessOutput({ pid, stream: "stderr", chunk: c });
811
840
  if (stderr.length < max) stderr += s;
812
841
  spool.write(s);
813
842
  queue.push({ kind: "err", data: s });
@@ -825,9 +854,25 @@ async function* spawnStream(opts) {
825
854
  queue.push({ kind: "error", data: e.message });
826
855
  wake();
827
856
  });
828
- child.on("close", (code) => {
857
+ const completeTelemetry = (code, signal, timedOut = false) => {
858
+ if (telemetryCompleted) return;
859
+ telemetryCompleted = true;
860
+ emitProcessCompleted({
861
+ ...pid !== void 0 ? { pid } : {},
862
+ exitCode: code,
863
+ ...signal ? { signal } : {},
864
+ durationMs: Date.now() - processStartedAt,
865
+ stdoutBytes,
866
+ stderrBytes,
867
+ timedOut,
868
+ endedAt: (/* @__PURE__ */ new Date()).toISOString()
869
+ });
870
+ };
871
+ child.on("close", (code, signal) => {
829
872
  if (typeof pid === "number") registry.unregister(pid);
830
- queue.push({ kind: "close", data: "", code: code ?? 0 });
873
+ const exitCode2 = code ?? (signal ? 1 : 0);
874
+ completeTelemetry(exitCode2, signal ?? void 0);
875
+ queue.push({ kind: "close", data: "", code: exitCode2, ...signal ? { signal } : {} });
831
876
  wake();
832
877
  });
833
878
  const onAbort = () => {
@@ -840,6 +885,7 @@ async function* spawnStream(opts) {
840
885
  }
841
886
  }
842
887
  queue.push({ kind: "close", data: "", code: 124 });
888
+ completeTelemetry(124, "SIGKILL", true);
843
889
  wake();
844
890
  };
845
891
  if (isWin) {
@@ -2368,13 +2414,14 @@ async function detectLanguageWorkspaces(options) {
2368
2414
  const target = options.target ? await canonicalInside(resolveFrom(cwd, options.target), projectRoot, "target") : void 0;
2369
2415
  const profiles = (options.profiles ?? languageProfileRegistry.list()).filter((profile) => !options.language || profile.id === options.language).slice().sort((a, b) => a.id.localeCompare(b.id));
2370
2416
  const limits = normalizeLimits(options.limits);
2417
+ const extraIgnores = new Set(options.ignoredDirectories ?? []);
2371
2418
  const state = {
2372
2419
  entries: 0,
2373
2420
  truncated: false,
2374
2421
  sourcePaths: /* @__PURE__ */ new Map(),
2375
2422
  candidates: /* @__PURE__ */ new Map()
2376
2423
  };
2377
- await scanDirectory(projectRoot, 0, profiles, limits, state, options.signal);
2424
+ await scanDirectory(projectRoot, 0, profiles, limits, state, extraIgnores, options.signal);
2378
2425
  addSourceFallbacks(projectRoot, profiles, state);
2379
2426
  if (target) addTargetEvidence(target, projectRoot, profiles, state);
2380
2427
  const workspaces = await Promise.all(
@@ -2388,7 +2435,7 @@ async function detectLanguageWorkspaces(options) {
2388
2435
  workspaces
2389
2436
  };
2390
2437
  }
2391
- async function scanDirectory(directory, depth, profiles, limits, state, signal) {
2438
+ async function scanDirectory(directory, depth, profiles, limits, state, extraIgnores, signal) {
2392
2439
  signal?.throwIfAborted();
2393
2440
  if (depth > limits.maxDepth || state.entries >= limits.maxEntries) {
2394
2441
  state.truncated = true;
@@ -2411,12 +2458,12 @@ async function scanDirectory(directory, depth, profiles, limits, state, signal)
2411
2458
  const fullPath = path4.join(directory, entry.name);
2412
2459
  if (entry.isSymbolicLink()) continue;
2413
2460
  if (entry.isDirectory()) {
2414
- if (shouldIgnoreDirectory(entry.name, profiles)) continue;
2461
+ if (shouldIgnoreDirectory(entry.name, profiles, extraIgnores)) continue;
2415
2462
  if (depth >= limits.maxDepth) {
2416
2463
  state.truncated = true;
2417
2464
  continue;
2418
2465
  }
2419
- await scanDirectory(fullPath, depth + 1, profiles, limits, state, signal);
2466
+ await scanDirectory(fullPath, depth + 1, profiles, limits, state, extraIgnores, signal);
2420
2467
  continue;
2421
2468
  }
2422
2469
  if (!entry.isFile()) continue;
@@ -2545,8 +2592,8 @@ function getCandidate(state, profile, root) {
2545
2592
  }
2546
2593
  return candidate;
2547
2594
  }
2548
- function shouldIgnoreDirectory(name, profiles) {
2549
- if (GLOBAL_IGNORES.has(name) || name.startsWith(".")) return true;
2595
+ function shouldIgnoreDirectory(name, profiles, extraIgnores) {
2596
+ if (GLOBAL_IGNORES.has(name) || extraIgnores.has(name) || name.startsWith(".")) return true;
2550
2597
  return profiles.some((profile) => profile.ignoredDirectories.includes(name));
2551
2598
  }
2552
2599
  function normalizeLimits(input) {