@wrongstack/plugins 0.307.1 → 0.308.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.
@@ -1,3 +1,6 @@
1
+ // src/prompt-firewall/index.ts
2
+ import { performance } from "node:perf_hooks";
3
+
1
4
  // src/runtime/credential-patterns.ts
2
5
  var CREDENTIAL_PATTERNS = [
3
6
  // LLM provider keys
@@ -130,6 +133,83 @@ function cloneCredentialPatterns() {
130
133
  }));
131
134
  }
132
135
 
136
+ // src/runtime/redos-guard.ts
137
+ import { Worker } from "node:worker_threads";
138
+ function withReDoSGuard(re, input, budgetMs = 50, options = {}) {
139
+ const opts = { budgetMs, ...options };
140
+ const start = Date.now();
141
+ const workerSource = buildWorkerSource(re.source, input, re.flags);
142
+ const worker = new Worker(workerSource, {
143
+ eval: true,
144
+ name: `redos-guard:${re.source.slice(0, 32)}`
145
+ });
146
+ return new Promise((resolve) => {
147
+ let settled = false;
148
+ const onMessage = (msg) => {
149
+ if (settled) return;
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ worker.terminate().catch(() => {
153
+ });
154
+ if (!msg.ok) {
155
+ resolve({ timedOut: true, match: null });
156
+ return;
157
+ }
158
+ resolve({ timedOut: false, match: msg.match });
159
+ };
160
+ const onError = () => {
161
+ if (settled) return;
162
+ settled = true;
163
+ clearTimeout(timer);
164
+ worker.terminate().catch(() => {
165
+ });
166
+ resolve({ timedOut: true, match: null });
167
+ };
168
+ const timer = setTimeout(() => {
169
+ if (settled) return;
170
+ settled = true;
171
+ const elapsedMs = Date.now() - start;
172
+ worker.terminate().catch(() => {
173
+ });
174
+ try {
175
+ opts.onTimeout?.({
176
+ regex: re,
177
+ input,
178
+ budgetMs: opts.budgetMs,
179
+ elapsedMs
180
+ });
181
+ } catch {
182
+ }
183
+ resolve({ timedOut: true, match: null });
184
+ }, opts.budgetMs);
185
+ timer.unref?.();
186
+ worker.on("message", onMessage);
187
+ worker.on("error", onError);
188
+ });
189
+ }
190
+ function buildWorkerSource(source, input, flags) {
191
+ const S = JSON.stringify(source);
192
+ const I = JSON.stringify(input);
193
+ const F = JSON.stringify(flags);
194
+ return `
195
+ const { parentPort } = require('node:worker_threads');
196
+ const source = ${S};
197
+ const input = ${I};
198
+ const flags = ${F};
199
+ try {
200
+ const re = new RegExp(source, flags);
201
+ const match = re.exec(input);
202
+ // parentPort.postMessage, NOT bare postMessage: with eval:true
203
+ // workers this Node version does not expose the bare postMessage
204
+ // global \u2014 the worker throws ReferenceError at startup and the
205
+ // host misreads it as a timeout (positive-path regression).
206
+ parentPort.postMessage({ ok: true, match });
207
+ } catch (err) {
208
+ parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
209
+ }
210
+ `;
211
+ }
212
+
133
213
  // src/prompt-firewall/index.ts
134
214
  var KIND_ALIASES = {
135
215
  aws_access_key: "aws-access-key",
@@ -169,32 +249,161 @@ var PATTERNS = [
169
249
  })),
170
250
  ...EXTRA_PATTERNS
171
251
  ];
172
- function detectSecrets(text, allow) {
173
- const counts = /* @__PURE__ */ new Map();
174
- for (const p of PATTERNS) {
175
- p.re.lastIndex = 0;
176
- let m = p.re.exec(text);
252
+ var PATTERN_BUDGET_MS = 250;
253
+ var GUARD_PROBE_LENGTH = 1e5;
254
+ var GUARD_PROBE_OVERLAP = 4096;
255
+ var SCAN_PASS_BUDGET_MS = 250;
256
+ function createScanDeadline() {
257
+ return { deadline: performance.now() + SCAN_PASS_BUDGET_MS, tripped: /* @__PURE__ */ new Set() };
258
+ }
259
+ var SCAN_WINDOW_STRIDE = GUARD_PROBE_LENGTH - GUARD_PROBE_OVERLAP;
260
+ var SCAN_WINDOW_TAIL = 65536;
261
+ var SCAN_WINDOW_LIMIT = GUARD_PROBE_LENGTH + SCAN_WINDOW_TAIL;
262
+ var SCAN_MAX_GROWTH = 16;
263
+ function growMatch(re, p, text, absStart, deadline) {
264
+ let size = GUARD_PROBE_LENGTH;
265
+ for (let grown = 0; grown < SCAN_MAX_GROWTH; grown++) {
266
+ if (deadline && performance.now() > deadline.deadline) {
267
+ deadline.tripped.add(p.kind);
268
+ return null;
269
+ }
270
+ const extEnd = Math.min(text.length, absStart + size);
271
+ const ext = text.slice(absStart, extEnd);
272
+ re.lastIndex = 0;
273
+ const em = re.exec(ext);
274
+ if (!em || em.index !== 0 || em[0].length === 0) return null;
275
+ const end = absStart + em[0].length;
276
+ if (end < extEnd || extEnd === text.length) {
277
+ return { start: absStart, end, matched: em[0] };
278
+ }
279
+ size *= 2;
280
+ }
281
+ return null;
282
+ }
283
+ function* execWindowed(p, text, deadline) {
284
+ const re = new RegExp(p.re.source, p.re.flags);
285
+ let acceptLo = 0;
286
+ let highWater = 0;
287
+ for (let window = 0; acceptLo < text.length; window++) {
288
+ const sliceStart = window === 0 ? 0 : acceptLo - GUARD_PROBE_OVERLAP;
289
+ const sliceEnd = Math.min(text.length, sliceStart + SCAN_WINDOW_LIMIT);
290
+ const slice = text.slice(sliceStart, sliceEnd);
291
+ const acceptHi = Math.min(acceptLo + SCAN_WINDOW_STRIDE, text.length);
292
+ re.lastIndex = 0;
293
+ let m = re.exec(slice);
177
294
  while (m !== null) {
295
+ if (deadline && performance.now() > deadline.deadline) {
296
+ deadline.tripped.add(p.kind);
297
+ return;
298
+ }
178
299
  const matched = m[0];
179
- if (!allow.some((a) => a.test(matched))) {
180
- counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
300
+ if (matched.length === 0) {
301
+ re.lastIndex += 1;
302
+ m = re.exec(slice);
303
+ continue;
304
+ }
305
+ const absStart = sliceStart + m.index;
306
+ const absEnd = absStart + matched.length;
307
+ if (absStart >= acceptLo && absStart < acceptHi) {
308
+ let final = { start: absStart, end: absEnd, matched };
309
+ if (absEnd === sliceEnd && sliceEnd < text.length) {
310
+ final = growMatch(re, p, text, absStart, deadline) ?? final;
311
+ re.lastIndex = m.index + final.matched.length;
312
+ }
313
+ if (final.start >= highWater) {
314
+ highWater = Math.max(highWater, final.end);
315
+ yield final;
316
+ }
181
317
  }
182
- m = p.re.exec(text);
318
+ m = re.exec(slice);
183
319
  }
320
+ acceptLo += SCAN_WINDOW_STRIDE;
184
321
  }
322
+ }
323
+ function countMatches(p, text, allow, counts, deadline) {
324
+ if (deadline && performance.now() > deadline.deadline) {
325
+ deadline.tripped.add(p.kind);
326
+ return;
327
+ }
328
+ for (const m of execWindowed(p, text, deadline)) {
329
+ if (!allow.some((a) => a.test(m.matched))) {
330
+ counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
331
+ }
332
+ }
333
+ }
334
+ function replacePattern(p, text, allow, redactions, deadline) {
335
+ if (deadline && performance.now() > deadline.deadline) {
336
+ deadline.tripped.add(p.kind);
337
+ return text;
338
+ }
339
+ let out = "";
340
+ let copied = 0;
341
+ for (const m of execWindowed(p, text, deadline)) {
342
+ out += text.slice(copied, m.start);
343
+ if (allow.some((a) => a.test(m.matched))) {
344
+ out += m.matched;
345
+ } else {
346
+ redactions.n += 1;
347
+ out += `[REDACTED:${p.kind}]`;
348
+ }
349
+ copied = m.end;
350
+ }
351
+ return out + text.slice(copied);
352
+ }
353
+ function detectSecrets(text, allow) {
354
+ const counts = /* @__PURE__ */ new Map();
355
+ for (const p of PATTERNS) countMatches(p, text, allow, counts);
185
356
  return [...counts.entries()].map(([kind, count]) => ({ kind, count }));
186
357
  }
187
- function redactSecrets(text, allow) {
358
+ function redactSecrets(text, allow, deadline) {
359
+ const redactions = { n: 0 };
188
360
  let out = text;
189
- let redactions = 0;
190
- for (const p of PATTERNS) {
191
- out = out.replace(new RegExp(p.re.source, p.re.flags), (match) => {
192
- if (allow.some((a) => a.test(match))) return match;
193
- redactions += 1;
194
- return `[REDACTED:${p.kind}]`;
361
+ for (const p of PATTERNS) out = replacePattern(p, out, allow, redactions, deadline);
362
+ return { text: out, redactions: redactions.n };
363
+ }
364
+ var DISTINCT_PATTERN_KINDS = new Set(PATTERNS.map((p) => p.kind)).size;
365
+ async function probeTimedOutPatterns(text) {
366
+ const timedOut = /* @__PURE__ */ new Set();
367
+ if (text.length === 0) return timedOut;
368
+ const probeWindow = (offset) => {
369
+ const window = text.slice(offset, offset + GUARD_PROBE_LENGTH);
370
+ const combined = new RegExp(PATTERNS.map((p) => `(${p.re.source})`).join("|"), "gi");
371
+ return withReDoSGuard(combined, window, PATTERN_BUDGET_MS).then(async (combinedResult) => {
372
+ if (!combinedResult.timedOut) return;
373
+ for (const p of PATTERNS) {
374
+ if (timedOut.has(p.kind)) continue;
375
+ const result = await withReDoSGuard(p.re, window, PATTERN_BUDGET_MS);
376
+ if (result.timedOut) timedOut.add(p.kind);
377
+ }
195
378
  });
379
+ };
380
+ const stride = GUARD_PROBE_LENGTH - GUARD_PROBE_OVERLAP;
381
+ for (let offset = 0; offset < text.length; offset += stride) {
382
+ if (timedOut.size >= DISTINCT_PATTERN_KINDS) break;
383
+ await probeWindow(offset);
196
384
  }
197
- return { text: out, redactions };
385
+ return timedOut;
386
+ }
387
+ async function detectSecretsGuarded(text, allow) {
388
+ const timedOut = await probeTimedOutPatterns(text);
389
+ const deadline = createScanDeadline();
390
+ const counts = /* @__PURE__ */ new Map();
391
+ for (const p of PATTERNS) {
392
+ if (!timedOut.has(p.kind)) countMatches(p, text, allow, counts, deadline);
393
+ }
394
+ const allSkipped = /* @__PURE__ */ new Set([...timedOut, ...deadline.tripped]);
395
+ return {
396
+ detections: [...counts.entries()].map(([kind, count]) => ({ kind, count })),
397
+ skipped: [...allSkipped].sort().map((kind) => ({ kind, reason: "redos-timeout" }))
398
+ };
399
+ }
400
+ function redactSecretsGuarded(text, allow, skip, deadline) {
401
+ const redactions = { n: 0 };
402
+ let out = text;
403
+ for (const p of PATTERNS) {
404
+ if (!skip.has(p.kind)) out = replacePattern(p, out, allow, redactions, deadline);
405
+ }
406
+ return { text: out, redactions: redactions.n };
198
407
  }
199
408
  function collectText(request) {
200
409
  const parts = [];
@@ -208,17 +417,27 @@ function collectText(request) {
208
417
  walk(request["messages"]);
209
418
  return parts.join("\n");
210
419
  }
211
- function redactDeep(value, allow, counter) {
420
+ var RESPONSE_SCAN_BUDGET = 1e6;
421
+ function redactDeep(value, allow, counter, skip, budget, deadline) {
212
422
  if (typeof value === "string") {
213
- const { text, redactions } = redactSecrets(value, allow);
423
+ if (budget) {
424
+ if (value.length > budget.remaining) {
425
+ budget.remaining = 0;
426
+ budget.truncated = true;
427
+ return value;
428
+ }
429
+ budget.remaining -= value.length;
430
+ }
431
+ const { text, redactions } = skip ? redactSecretsGuarded(value, allow, skip, deadline) : redactSecrets(value, allow, deadline);
214
432
  counter.n += redactions;
215
433
  return text;
216
434
  }
217
- if (Array.isArray(value)) return value.map((v) => redactDeep(v, allow, counter));
435
+ if (Array.isArray(value))
436
+ return value.map((v) => redactDeep(v, allow, counter, skip, budget, deadline));
218
437
  if (value && typeof value === "object") {
219
438
  const out = {};
220
439
  for (const [k, v] of Object.entries(value)) {
221
- out[k] = redactDeep(v, allow, counter);
440
+ out[k] = redactDeep(v, allow, counter, skip, budget, deadline);
222
441
  }
223
442
  return out;
224
443
  }
@@ -253,16 +472,34 @@ var state = {
253
472
  requestRedactions: 0,
254
473
  responseRedactions: 0,
255
474
  blocked: 0,
475
+ timeoutCount: 0,
476
+ skippedPatterns: [],
477
+ responseTruncated: false,
256
478
  byKind: /* @__PURE__ */ new Map(),
257
479
  lastDetection: null,
258
480
  extensionUnregister: null
259
481
  };
482
+ function surfaceScanTrips(api, tripped) {
483
+ for (const kind of tripped) {
484
+ if (!state.skippedPatterns.includes(kind)) state.skippedPatterns.push(kind);
485
+ }
486
+ state.timeoutCount += 1;
487
+ api.metrics.counter("redos_skips", 1);
488
+ api.log.warn("prompt-firewall: scan-pass budget exceeded \u2014 patterns skipped mid-pass (issue #370)", {
489
+ skipped: [...tripped]
490
+ });
491
+ }
260
492
  var plugin = {
261
493
  name: "prompt-firewall",
262
494
  version: "0.1.0",
263
495
  description: "Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.",
264
496
  apiVersion: "^0.1.10",
265
497
  capabilities: { tools: true },
498
+ // Wrap-stack contract (issue #362): ExtensionRegistry composes wrappers
499
+ // first-registered = outermost. The manifest lists this plugin before
500
+ // llm-cache, and llm-cache declares this plugin in optionalDeps, so the
501
+ // firewall is the outer wrap: every request is scanned/redacted before
502
+ // llm-cache can fingerprint or cache it.
266
503
  defaultConfig: { enabled: false, mode: "redact", scanResponse: true, allow: [] },
267
504
  configSchema: {
268
505
  type: "object",
@@ -297,6 +534,9 @@ var plugin = {
297
534
  state.requestRedactions = 0;
298
535
  state.responseRedactions = 0;
299
536
  state.blocked = 0;
537
+ state.timeoutCount = 0;
538
+ state.skippedPatterns = [];
539
+ state.responseTruncated = false;
300
540
  state.byKind.clear();
301
541
  state.lastDetection = null;
302
542
  if (state.extensionUnregister) {
@@ -319,7 +559,17 @@ var plugin = {
319
559
  async wrapProviderRunner(_ctx, request, inner) {
320
560
  const req = request ?? {};
321
561
  state.invocations += 1;
322
- const detections = detectSecrets(collectText(req), cfg.allow);
562
+ const requestText = collectText(req);
563
+ const { detections, skipped } = await detectSecretsGuarded(requestText, cfg.allow);
564
+ state.skippedPatterns = skipped.map((s) => s.kind);
565
+ if (skipped.length > 0) {
566
+ state.timeoutCount += 1;
567
+ api.log.warn("prompt-firewall: ReDoS budget exceeded, patterns skipped", {
568
+ skipped: state.skippedPatterns
569
+ });
570
+ api.metrics.counter("redos_skips", 1);
571
+ }
572
+ const skipSet = new Set(state.skippedPatterns);
323
573
  if (detections.length > 0) {
324
574
  state.requestsWithSecrets += 1;
325
575
  for (const d of detections) {
@@ -338,27 +588,39 @@ var plugin = {
338
588
  }
339
589
  if (cfg.mode === "redact") {
340
590
  const counter = { n: 0 };
341
- const redactedReq = redactDeep(req, cfg.allow, counter);
591
+ const deadline = createScanDeadline();
592
+ const redactedReq = redactDeep(req, cfg.allow, counter, skipSet, void 0, deadline);
342
593
  state.requestRedactions += counter.n;
343
594
  api.metrics.counter("request_redactions", counter.n);
595
+ if (deadline.tripped.size > 0) surfaceScanTrips(api, deadline.tripped);
344
596
  const response2 = await inner(_ctx, redactedReq);
345
- return cfg.scanResponse ? redactResponse(response2, cfg.allow) : response2;
597
+ return cfg.scanResponse ? redactResponse(response2, cfg.allow, skipSet) : response2;
346
598
  }
347
599
  }
348
600
  const response = await inner(_ctx, request);
349
601
  if (cfg.mode === "redact" && cfg.scanResponse) {
350
- return redactResponse(response, cfg.allow);
602
+ return redactResponse(response, cfg.allow, skipSet);
351
603
  }
352
604
  return response;
353
605
  }
354
606
  });
355
607
  }
356
- function redactResponse(response, allow) {
608
+ function redactResponse(response, allow, skip) {
357
609
  if (!response || typeof response !== "object") return response;
358
610
  const counter = { n: 0 };
359
611
  const content = response.content;
360
612
  if (content === void 0) return response;
361
- const redacted = redactDeep(content, allow, counter);
613
+ const budget = { remaining: RESPONSE_SCAN_BUDGET, truncated: false };
614
+ const deadline = createScanDeadline();
615
+ const redacted = redactDeep(content, allow, counter, skip, budget, deadline);
616
+ if (deadline.tripped.size > 0) surfaceScanTrips(api, deadline.tripped);
617
+ if (budget.truncated) {
618
+ state.responseTruncated = true;
619
+ api.log.warn(
620
+ "prompt-firewall: response scan budget exhausted \u2014 part of the response was returned unredacted"
621
+ );
622
+ api.metrics.counter("response_scan_truncated", 1);
623
+ }
362
624
  if (counter.n > 0) {
363
625
  state.responseRedactions += counter.n;
364
626
  api.metrics.counter("response_redactions", counter.n);
@@ -384,12 +646,15 @@ var plugin = {
384
646
  mode: cfg.mode,
385
647
  scanResponse: cfg.scanResponse,
386
648
  patterns: PATTERNS.map((p) => p.kind),
649
+ skippedPatterns: state.skippedPatterns,
650
+ responseTruncated: state.responseTruncated,
387
651
  counters: {
388
652
  invocations: state.invocations,
389
653
  requestsWithSecrets: state.requestsWithSecrets,
390
654
  requestRedactions: state.requestRedactions,
391
655
  responseRedactions: state.responseRedactions,
392
- blocked: state.blocked
656
+ blocked: state.blocked,
657
+ timeoutCount: state.timeoutCount
393
658
  },
394
659
  byKind: Object.fromEntries(state.byKind),
395
660
  lastDetection: state.lastDetection
@@ -416,13 +681,17 @@ var plugin = {
416
681
  requestsWithSecrets: state.requestsWithSecrets,
417
682
  requestRedactions: state.requestRedactions,
418
683
  responseRedactions: state.responseRedactions,
419
- blocked: state.blocked
684
+ blocked: state.blocked,
685
+ timeoutCount: state.timeoutCount
420
686
  };
421
687
  state.invocations = 0;
422
688
  state.requestsWithSecrets = 0;
423
689
  state.requestRedactions = 0;
424
690
  state.responseRedactions = 0;
425
691
  state.blocked = 0;
692
+ state.timeoutCount = 0;
693
+ state.skippedPatterns = [];
694
+ state.responseTruncated = false;
426
695
  state.byKind.clear();
427
696
  state.lastDetection = null;
428
697
  api.log.info("prompt-firewall: teardown complete", { final });
@@ -443,8 +712,12 @@ var plugin = {
443
712
  };
444
713
  var prompt_firewall_default = plugin;
445
714
  export {
715
+ KIND_ALIASES,
716
+ SCAN_WINDOW_LIMIT,
446
717
  prompt_firewall_default as default,
447
718
  detectSecrets,
719
+ detectSecretsGuarded,
448
720
  readConfig,
449
- redactSecrets
721
+ redactSecrets,
722
+ redactSecretsGuarded
450
723
  };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * H1 idempotent state — single-slot plugin state with a registry of
3
+ * releasable handles that survives `setup()` reload cycles.
4
+ *
5
+ * The "H1 audit pattern" (per SAGE memory T-03) is documented across
6
+ * the plugin suite: a plugin's module-scope `state` object holds
7
+ * counters plus a `hookUnregister` (or `extensionUnregister`) slot;
8
+ * on reload, the slot MUST be released before a new one is stored.
9
+ * Every plugin implements this inline with subtle variations:
10
+ *
11
+ * - some use `releaseHandle(state.hookUnregister)` (`accessibility-auditor`)
12
+ * - some use `try { state.hookUnregister(); } catch {}` (`config-validator`)
13
+ * - some use a single inline `if (state.hookUnregister) { … }` block
14
+ *
15
+ * The drift cost: in 4 plugins the prior handle was leaked on reload
16
+ * because the inline `if` check raced with the new registration.
17
+ * This helper centralises the contract.
18
+ *
19
+ * Contract:
20
+ * `createH1State<T>(initial)` returns
21
+ * {
22
+ * state: T, // the user's mutable state
23
+ * register: (key, unregister) => void,
24
+ * release: (key) => void,
25
+ * releaseAll: () => void,
26
+ * }
27
+ *
28
+ * - `register(key, unregister)` releases any prior handle at `key`
29
+ * before storing the new one.
30
+ * - `release(key)` is a no-op if no handle is registered.
31
+ * - `releaseAll()` releases every registered handle and clears the map.
32
+ * - A throwing unregister function is swallowed (best-effort), matching
33
+ * the existing `releaseHandle` semantics at `runtime/handles.ts`.
34
+ *
35
+ * The state object itself is NOT reset by `releaseAll` — counter
36
+ * reset is the plugin's responsibility (it knows the semantics of its
37
+ * counters). This helper owns the handle lifecycle only.
38
+ */
39
+ export type Unregister = () => void;
40
+ export interface H1State<T> {
41
+ /** The plugin's mutable state. Owned by the caller; never reset by this helper. */
42
+ state: T;
43
+ /**
44
+ * Register an unregister function under `key`. Any prior handle at
45
+ * `key` is released first. Throwing unregister functions are
46
+ * swallowed.
47
+ */
48
+ register: (key: string, unregister: Unregister | null | undefined) => void;
49
+ /**
50
+ * Release the handle at `key` (if any). Idempotent. Throwing
51
+ * unregister functions are swallowed.
52
+ */
53
+ release: (key: string) => void;
54
+ /** Release every registered handle. Idempotent. */
55
+ releaseAll: () => void;
56
+ /** Number of currently registered handles. Observability for health()/status tools. */
57
+ size: () => number;
58
+ /** List the registered keys. Order is insertion order; useful for diagnostics. */
59
+ keys: () => string[];
60
+ }
61
+ export declare function createH1State<T>(initial: T): H1State<T>;
62
+ //# sourceMappingURL=h1-state.d.ts.map
@@ -32,6 +32,9 @@ export { parseLlmJsonObject, runOptionalPluginCouncil, runOptionalPluginLlm, str
32
32
  export { BoundedMap, BoundedSet, type BoundedMapOptions } from './bounded-map.js';
33
33
  export { UNSERIALIZABLE, safeJsonStringify } from './safe-json.js';
34
34
  export { releaseHandle, releaseHandles, type Unregister } from './handles.js';
35
+ export { withReDoSGuard, guardedMatcher, type ReDoSResult, type ReDoSOptions, } from './redos-guard.js';
36
+ export { safePath, isInsideProject, type SafePathOptions, } from './sandbox.js';
37
+ export { createH1State, type H1State, } from './h1-state.js';
35
38
  export { clearLocalBinCache, findOnPath, resolveExecInvocation, resolveFirstNodeBin, resolveNodeBin, resolveWin32Command, type ExecInvocation, type ResolvedNodeBin, } from './local-bin.js';
36
39
  export type LanguageId = 'typescript' | 'javascript' | 'python' | 'go' | 'rust' | 'shell' | 'ruby' | 'java' | 'kotlin' | 'dotnet' | 'generic';
37
40
  export type PackageManagerId = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'pip' | 'poetry' | 'go' | 'cargo' | 'gem' | 'maven' | 'gradle' | 'dotnet' | 'none';
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Redos guard — run a regex inside a `node:worker_threads` worker so
3
+ * the host can actually terminate it on a wall-clock budget.
4
+ *
5
+ * Why a worker thread?
6
+ * A `setTimeout`-based watchdog cannot interrupt a synchronous
7
+ * CPU-bound regex in Node.js's single-threaded event loop. The
8
+ * `setImmediate`/`setTimeout` race fires whichever wins the next
9
+ * event-loop tick; if the regex blocks the loop synchronously for
10
+ * 7 seconds, the timer has long since fired and the regex still
11
+ * returns a result — only after the loop is unblocked does the
12
+ * `setImmediate` callback resume and resolve `{ timedOut: false }`.
13
+ *
14
+ * The only honest fix is to run the regex in a separate thread that
15
+ * the host can `worker.terminate()`. `node:worker_threads` gives us
16
+ * that, and the per-thread cost is amortized by the runtime helper
17
+ * itself (the host doesn't pay for the thread except when it
18
+ * invokes `withReDoSGuard`).
19
+ *
20
+ * Why is this here, not inside each plugin?
21
+ * Three plugins (`secret-scanner`, `prompt-firewall`, `path-guard`)
22
+ * need the same contract. Three copies would drift; one copy is
23
+ * auditable and testable.
24
+ *
25
+ * Contract:
26
+ * `withReDoSGuard(re, input, ms)` returns:
27
+ * { timedOut: false, match: RegExpExecArray | null } on normal completion
28
+ * { timedOut: true, match: null } on timeout
29
+ */
30
+ export interface ReDoSResult {
31
+ /** True when the regex did not complete within the wall-clock budget. */
32
+ timedOut: boolean;
33
+ /** The match result (groups, indices) when `timedOut === false`; null otherwise. */
34
+ match: RegExpExecArray | null;
35
+ }
36
+ export interface ReDoSOptions {
37
+ /** Wall-clock budget in ms. Default 50. */
38
+ budgetMs?: number;
39
+ /**
40
+ * Optional hook invoked exactly once when the budget is exceeded.
41
+ * Called synchronously after the regex is terminated. Default: no-op.
42
+ */
43
+ onTimeout?: (info: {
44
+ regex: RegExp;
45
+ input: string;
46
+ budgetMs: number;
47
+ elapsedMs: number;
48
+ }) => void;
49
+ }
50
+ /**
51
+ * Run `re.exec(input)` inside a worker thread with a wall-clock
52
+ * watchdog. The worker is terminated when the budget elapses; the
53
+ * regex cannot keep running.
54
+ *
55
+ * Returns a Promise; resolved with `{ timedOut, match }`.
56
+ */
57
+ export declare function withReDoSGuard(re: RegExp, input: string, budgetMs?: number, options?: ReDoSOptions): Promise<ReDoSResult>;
58
+ /**
59
+ * Convenience: build a guarded matcher.
60
+ *
61
+ * ```ts
62
+ * const matchCredential = guardedMatcher(/AKIA[0-9A-Z]{16}/g, 25);
63
+ * const r = await matchCredential(line);
64
+ * if (r.timedOut) counters.redosTimeouts++;
65
+ * else if (r.match) report(r.match);
66
+ * ```
67
+ */
68
+ export declare function guardedMatcher(re: RegExp, budgetMs?: number, onTimeout?: ReDoSOptions['onTimeout']): (input: string) => Promise<ReDoSResult>;
69
+ //# sourceMappingURL=redos-guard.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Sandbox — canonical project-path validation with symlink resolution.
3
+ *
4
+ * Why a new helper when `runtime/index.ts` already exports
5
+ * `withinProject` and `sanitizeRunnerPath`?
6
+ *
7
+ * - `withinProject` does NOT resolve symlinks: a path like
8
+ * `project/link-to-/etc/passwd` reports as inside the project
9
+ * even though the resolved target is outside.
10
+ * - `sanitizeRunnerPath` is for runner argv (linter binaries),
11
+ * not user-supplied tool input. The two paths share the
12
+ * leading-dash + length checks but `sanitizeRunnerPath`
13
+ * rejects a `cwd` for which it cannot resolve; user-input
14
+ * sandbox needs canonicalization instead.
15
+ *
16
+ * The previous design (per SAGE memory T-03) relied on three
17
+ * duplicate `withinProject` copies in `runtime/index.ts`,
18
+ * `file-watcher/index.ts`, and `path-guard/glob.ts`. They drifted
19
+ * on edge cases. This helper is the single replacement.
20
+ *
21
+ * Contract:
22
+ * `safePath(input, projectRoot?)` returns the canonical absolute
23
+ * path inside the project on success, or `null` on rejection.
24
+ *
25
+ * Rejection cases:
26
+ * - empty string
27
+ * - length > 4096 bytes (matches `runtime.withinProject`)
28
+ * - leading-dash (option smuggling)
29
+ * - cannot be resolved (path doesn't exist or EPERM)
30
+ * - resolved path escapes the project root
31
+ * - symlink target is outside the project
32
+ */
33
+ export interface SafePathOptions {
34
+ /**
35
+ * Project root for the sandbox. Defaults to `process.cwd()`. Pass
36
+ * the session cwd from the plugin host when available so that
37
+ * tools running in a subdirectory are scoped to that directory.
38
+ */
39
+ projectRoot?: string;
40
+ /**
41
+ * If true (default), follow symlinks via `realpathSync`. Set false
42
+ * for plugins that need to record the literal path the user wrote
43
+ * (e.g. checkpoint capture, git diff).
44
+ */
45
+ followSymlinks?: boolean;
46
+ }
47
+ /**
48
+ * Resolve `input` to an absolute path inside the project root.
49
+ *
50
+ * Returns `null` for empty/oversized/leading-dash inputs and for
51
+ * paths whose real target escapes the project.
52
+ */
53
+ export declare function safePath(input: string, options?: SafePathOptions): string | null;
54
+ /**
55
+ * Boolean convenience for callers that don't need the canonical path.
56
+ * Equivalent to `safePath(input, options) !== null`.
57
+ */
58
+ export declare function isInsideProject(input: string, options?: SafePathOptions): boolean;
59
+ //# sourceMappingURL=sandbox.d.ts.map