@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.
- package/dist/accessibility-auditor/index.d.ts +5 -0
- package/dist/accessibility-auditor.js +67 -11
- package/dist/audit/index.d.ts +6 -6
- package/dist/audit.js +7 -7
- package/dist/code-metrics/index.d.ts +9 -0
- package/dist/code-metrics.js +42 -11
- package/dist/dep-guard.js +4 -2
- package/dist/dependency-vulnerability-gate/index.d.ts +4 -0
- package/dist/dependency-vulnerability-gate.js +435 -62
- package/dist/factories/index.d.ts +1 -1
- package/dist/factories.js +1 -1
- package/dist/file-watcher.js +25 -10
- package/dist/format-on-save.js +10 -4
- package/dist/import-organizer/index.d.ts +4 -0
- package/dist/import-organizer.js +26 -1
- package/dist/index.js +719 -234
- package/dist/license-audit-gate/index.d.ts +1 -0
- package/dist/license-audit-gate.js +445 -83
- package/dist/lint-gate/index.d.ts +13 -0
- package/dist/lint-gate.js +13 -10
- package/dist/llm-cache.js +6 -0
- package/dist/manifest.js +1 -1
- package/dist/path-guard/glob.d.ts +23 -0
- package/dist/path-guard/index.d.ts +2 -0
- package/dist/path-guard.js +214 -32
- package/dist/plugin-audit-catalog.js +7 -7
- package/dist/prompt-firewall/index.d.ts +49 -40
- package/dist/prompt-firewall.js +302 -29
- package/dist/runtime/h1-state.d.ts +62 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/redos-guard.d.ts +69 -0
- package/dist/runtime/sandbox.d.ts +59 -0
- package/dist/runtime.js +210 -19
- package/dist/secret-scanner.js +16 -2
- package/dist/spec-linker.js +1 -1
- package/dist/type-gate.js +33 -3
- package/package.json +7 -7
package/dist/prompt-firewall.js
CHANGED
|
@@ -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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
180
|
-
|
|
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 =
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
-
|
|
420
|
+
var RESPONSE_SCAN_BUDGET = 1e6;
|
|
421
|
+
function redactDeep(value, allow, counter, skip, budget, deadline) {
|
|
212
422
|
if (typeof value === "string") {
|
|
213
|
-
|
|
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))
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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
|