@wrongstack/plugins 0.308.0 → 0.308.2
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/audit/index.d.ts +6 -6
- package/dist/audit.js +7 -7
- package/dist/factories/index.d.ts +1 -1
- package/dist/factories.js +1 -1
- package/dist/import-organizer.js +4 -0
- package/dist/index.js +285 -50
- package/dist/llm-cache.js +6 -0
- package/dist/manifest.js +1 -1
- package/dist/path-guard.js +68 -15
- package/dist/plugin-audit-catalog.js +7 -7
- package/dist/prompt-firewall/index.d.ts +43 -41
- package/dist/prompt-firewall.js +217 -34
- package/dist/spec-linker.js +1 -1
- package/package.json +7 -7
package/dist/manifest.js
CHANGED
|
@@ -35,10 +35,10 @@ var OFFICIAL_PLUGIN_NAMES = [
|
|
|
35
35
|
"notify-hub",
|
|
36
36
|
"changelog-writer",
|
|
37
37
|
"injection-shield",
|
|
38
|
+
"prompt-firewall",
|
|
38
39
|
"llm-cache",
|
|
39
40
|
"model-router",
|
|
40
41
|
"pr-drafter",
|
|
41
|
-
"prompt-firewall",
|
|
42
42
|
"auto-escalate",
|
|
43
43
|
"test-coverage-gate",
|
|
44
44
|
"type-gate",
|
package/dist/path-guard.js
CHANGED
|
@@ -562,14 +562,19 @@ function stripLauncherAtBoundary(command, launcher, valueTaking) {
|
|
|
562
562
|
}
|
|
563
563
|
return `${command.slice(0, match.index)}${boundary}${after.slice(consumed).replace(/^\s+/, "")}`;
|
|
564
564
|
}
|
|
565
|
+
var XARGS_OPTIONS = String.raw`(?:\s+(?:(?:-[InLsPjeE]|--(?:arg-file|replace|max-args|max-lines|max-chars|max-procs))\s+[^\s]+|-[^\s]+))*`;
|
|
566
|
+
var COMMAND_BOUNDARY = String.raw`(?:^|[;&|\r\n]\s*|\{\s*|(?<![$(])\(\s*|\bxargs${XARGS_OPTIONS}\s+)`;
|
|
565
567
|
function commandRecursivelyDeletes(command) {
|
|
566
568
|
const stripped = stripTransparentLaunchers(maskNonExecutingHeredocBodies(command));
|
|
567
|
-
if (/(?:^|[;&|\r\n]\s*|\$\(\s*|\(\s*|`\s*)find\b[^;&|)`]*(?:-delete\b|-exec(?:dir)?\s+(?:[^\s;&|]+[\\/])?(?:rm|rmdir)\b)/i.test(
|
|
569
|
+
if (/(?:^|[;&|\r\n]\s*|\{\s*|\$\(\s*|\(\s*|`\s*)find\b[^;&|)`]*(?:-delete\b|-exec(?:dir)?\s+(?:[^\s;&|]+[\\/])?(?:rm|rmdir)\b)/i.test(
|
|
568
570
|
stripped
|
|
569
571
|
)) {
|
|
570
572
|
return true;
|
|
571
573
|
}
|
|
572
|
-
const destructive =
|
|
574
|
+
const destructive = new RegExp(
|
|
575
|
+
String.raw`${COMMAND_BOUNDARY}(rm|rmdir|del|rd|Remove-Item)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)`,
|
|
576
|
+
"gi"
|
|
577
|
+
);
|
|
573
578
|
let match = destructive.exec(stripped);
|
|
574
579
|
while (match !== null) {
|
|
575
580
|
const tool = match[1]?.toLowerCase();
|
|
@@ -786,6 +791,27 @@ ${remainingCommand}`;
|
|
|
786
791
|
if (heredoc !== null) maskBody(heredoc.bodyStart, lines.length, heredoc.quoted);
|
|
787
792
|
return lines.join("");
|
|
788
793
|
}
|
|
794
|
+
function stripSubshellClosers(raw) {
|
|
795
|
+
let result = raw.trimEnd();
|
|
796
|
+
while (result.endsWith(")")) {
|
|
797
|
+
if (quoteIsEscaped(result, result.length - 1)) break;
|
|
798
|
+
let quote = null;
|
|
799
|
+
let depth = 0;
|
|
800
|
+
for (let index = 0; index < result.length - 1; index += 1) {
|
|
801
|
+
const char = result[index];
|
|
802
|
+
if (isQuoteBoundary(result, index, quote)) {
|
|
803
|
+
quote = quote === char ? null : char === "'" ? "'" : '"';
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (quote !== null) continue;
|
|
807
|
+
if (char === "(" && !quoteIsEscaped(result, index)) depth += 1;
|
|
808
|
+
else if (char === ")" && !quoteIsEscaped(result, index)) depth -= 1;
|
|
809
|
+
}
|
|
810
|
+
if (depth > 0) break;
|
|
811
|
+
result = result.slice(0, -1).trimEnd();
|
|
812
|
+
}
|
|
813
|
+
return result;
|
|
814
|
+
}
|
|
789
815
|
function destructiveTargets(command) {
|
|
790
816
|
return destructiveTargetsAtDepth(command, 0);
|
|
791
817
|
}
|
|
@@ -832,20 +858,26 @@ function destructiveTargetsAtDepth(command, depth) {
|
|
|
832
858
|
}
|
|
833
859
|
targets.push(...destructiveTargetsAtDepth(executableBody, depth + 1));
|
|
834
860
|
}
|
|
835
|
-
const destructive =
|
|
861
|
+
const destructive = new RegExp(
|
|
862
|
+
String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(rm|rmdir|del|rd|Remove-Item|unlink|truncate|shred|mv)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)`,
|
|
863
|
+
"gi"
|
|
864
|
+
);
|
|
836
865
|
let m = destructive.exec(normalizedCommand);
|
|
837
866
|
while (m !== null) {
|
|
838
867
|
if (!tokenIsQuoted(m, m[1] ?? "")) targets.push(...shellArgs(m[2] ?? ""));
|
|
839
868
|
m = destructive.exec(normalizedCommand);
|
|
840
869
|
}
|
|
841
|
-
const copy =
|
|
870
|
+
const copy = new RegExp(
|
|
871
|
+
String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(cp|install)\s+([^;&|\r\n]+)`,
|
|
872
|
+
"gi"
|
|
873
|
+
);
|
|
842
874
|
let c = copy.exec(normalizedCommand);
|
|
843
875
|
while (c !== null) {
|
|
844
876
|
if (tokenIsQuoted(c, c[1] ?? "")) {
|
|
845
877
|
c = copy.exec(normalizedCommand);
|
|
846
878
|
continue;
|
|
847
879
|
}
|
|
848
|
-
const tokens = shellTokens(c[2] ?? "");
|
|
880
|
+
const tokens = shellTokens(stripSubshellClosers(c[2] ?? ""));
|
|
849
881
|
let destination;
|
|
850
882
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
851
883
|
const token = tokens[index];
|
|
@@ -862,23 +894,32 @@ function destructiveTargetsAtDepth(command, depth) {
|
|
|
862
894
|
if (destination) targets.push(destination);
|
|
863
895
|
c = copy.exec(normalizedCommand);
|
|
864
896
|
}
|
|
865
|
-
const tee =
|
|
897
|
+
const tee = new RegExp(
|
|
898
|
+
String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(tee)\s+([^;&|\r\n]+)`,
|
|
899
|
+
"gi"
|
|
900
|
+
);
|
|
866
901
|
let t = tee.exec(normalizedCommand);
|
|
867
902
|
while (t !== null) {
|
|
868
903
|
if (!tokenIsQuoted(t, t[1] ?? "")) targets.push(...shellArgs(t[2] ?? ""));
|
|
869
904
|
t = tee.exec(normalizedCommand);
|
|
870
905
|
}
|
|
871
|
-
const dd =
|
|
906
|
+
const dd = new RegExp(
|
|
907
|
+
String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(dd)\s+([^;&|\r\n]+)`,
|
|
908
|
+
"gi"
|
|
909
|
+
);
|
|
872
910
|
let d = dd.exec(normalizedCommand);
|
|
873
911
|
while (d !== null) {
|
|
874
912
|
if (!tokenIsQuoted(d, d[1] ?? "")) {
|
|
875
913
|
const outputMatch = /(?:^|\s)of=("[^"]*"|'[^']*'|[^\s]+)/i.exec(d[2] ?? "");
|
|
876
|
-
const output = outputMatch?.[1]
|
|
914
|
+
const output = outputMatch?.[1] ? stripSubshellClosers(outputMatch[1]).replace(/^['"]|['"]$/g, "") : void 0;
|
|
877
915
|
if (output) targets.push(output);
|
|
878
916
|
}
|
|
879
917
|
d = dd.exec(normalizedCommand);
|
|
880
918
|
}
|
|
881
|
-
const overwrite =
|
|
919
|
+
const overwrite = new RegExp(
|
|
920
|
+
String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(sed|ln)\s+([^;&|\r\n]+)`,
|
|
921
|
+
"gi"
|
|
922
|
+
);
|
|
882
923
|
let o = overwrite.exec(normalizedCommand);
|
|
883
924
|
while (o !== null) {
|
|
884
925
|
const rawArgs = o[2] ?? "";
|
|
@@ -896,7 +937,10 @@ function destructiveTargetsAtDepth(command, depth) {
|
|
|
896
937
|
}
|
|
897
938
|
o = overwrite.exec(normalizedCommand);
|
|
898
939
|
}
|
|
899
|
-
const xargsPipeline =
|
|
940
|
+
const xargsPipeline = new RegExp(
|
|
941
|
+
String.raw`\b(?:echo|printf)\s+([^|]+)\|\s*xargs${XARGS_OPTIONS}\s+(?:sudo\s+)?(?:rm|rmdir|del|unlink|truncate|shred)\b`,
|
|
942
|
+
"gi"
|
|
943
|
+
);
|
|
900
944
|
let x = xargsPipeline.exec(normalizedCommand);
|
|
901
945
|
while (x !== null) {
|
|
902
946
|
if (!tokenIsQuoted(x, "xargs")) targets.push(...shellArgs(x[1] ?? ""));
|
|
@@ -1013,7 +1057,7 @@ function destructiveTargetsAtDepth(command, depth) {
|
|
|
1013
1057
|
targets.push(gitTarget("."));
|
|
1014
1058
|
}
|
|
1015
1059
|
}
|
|
1016
|
-
const findDelete = /(?:^|[;&|\r\n]\s*|\$\(\s*|\(\s*|`\s*)find\b([^;&|)`]*(?:\s-delete(
|
|
1060
|
+
const findDelete = /(?:^|[;&|\r\n]\s*|\{\s*|\$\(\s*|\(\s*|`\s*)find\b([^;&|)`]*(?:\s-delete(?:[\s;})]|$)|\s-exec(?:dir)?\s+(?:[^\s;&|]+[\\/])?(?:rm|rmdir)\b)[^;&|)`]*)/gi;
|
|
1017
1061
|
let f = findDelete.exec(normalizedCommand);
|
|
1018
1062
|
while (f !== null) {
|
|
1019
1063
|
const tokens = shellTokens(f[1] ?? "");
|
|
@@ -1471,10 +1515,19 @@ var plugin = {
|
|
|
1471
1515
|
const ti = input.toolInput ?? {};
|
|
1472
1516
|
const command = typeof ti["command"] === "string" ? ti["command"] : "";
|
|
1473
1517
|
const commandArgs = Array.isArray(ti["args"]) ? ti["args"].filter((arg) => typeof arg === "string") : [];
|
|
1474
|
-
const
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1518
|
+
const serializeArgForInspection = (arg) => {
|
|
1519
|
+
if (/^[\w./:@%+=,-]+$/.test(arg)) return arg;
|
|
1520
|
+
const assignment = /^([A-Za-z_]\w*)=(.+)$/.exec(arg);
|
|
1521
|
+
const key = assignment?.[1];
|
|
1522
|
+
const value = assignment?.[2];
|
|
1523
|
+
if (key !== void 0 && value !== void 0) {
|
|
1524
|
+
return `${key}="${value.replaceAll('"', "")}"`;
|
|
1525
|
+
}
|
|
1526
|
+
return JSON.stringify(arg);
|
|
1527
|
+
};
|
|
1528
|
+
const commandForInspection = [command, ...commandArgs.map(serializeArgForInspection)].join(
|
|
1529
|
+
" "
|
|
1530
|
+
);
|
|
1478
1531
|
if (command && executesShell({
|
|
1479
1532
|
toolName: input.toolName,
|
|
1480
1533
|
toolInput: ti,
|
|
@@ -245,6 +245,13 @@ var OFFICIAL_PLUGIN_AUDIT_ENTRIES = [
|
|
|
245
245
|
defaultState: "active",
|
|
246
246
|
canDisable: true
|
|
247
247
|
},
|
|
248
|
+
{
|
|
249
|
+
name: "prompt-firewall",
|
|
250
|
+
risk: "high",
|
|
251
|
+
summary: "Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.",
|
|
252
|
+
defaultState: "inactive",
|
|
253
|
+
canDisable: true
|
|
254
|
+
},
|
|
248
255
|
{
|
|
249
256
|
name: "llm-cache",
|
|
250
257
|
risk: "medium",
|
|
@@ -266,13 +273,6 @@ var OFFICIAL_PLUGIN_AUDIT_ENTRIES = [
|
|
|
266
273
|
defaultState: "inactive",
|
|
267
274
|
canDisable: true
|
|
268
275
|
},
|
|
269
|
-
{
|
|
270
|
-
name: "prompt-firewall",
|
|
271
|
-
risk: "high",
|
|
272
|
-
summary: "Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.",
|
|
273
|
-
defaultState: "inactive",
|
|
274
|
-
canDisable: true
|
|
275
|
-
},
|
|
276
276
|
{
|
|
277
277
|
name: "auto-escalate",
|
|
278
278
|
risk: "medium",
|
|
@@ -1,43 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* prompt-firewall plugin — inspects and redacts secrets on the provider
|
|
3
|
-
* wire, before context leaves for the LLM API and as it returns.
|
|
4
|
-
*
|
|
5
|
-
* Distinct from `secret-scanner` (which guards the TOOL boundary): this
|
|
6
|
-
* sits on `AgentExtension.wrapProviderRunner`, so it sees the FULL
|
|
7
|
-
* request that is about to be sent to a third-party LLM provider —
|
|
8
|
-
* regardless of how a secret entered the conversation. It scans the
|
|
9
|
-
* outgoing request's system + message text for high-confidence
|
|
10
|
-
* credential patterns and, depending on `mode`:
|
|
11
|
-
*
|
|
12
|
-
* - `warn` — logs + counts + emits a `prompt-firewall:leak`
|
|
13
|
-
* event; the request goes through unchanged
|
|
14
|
-
* - `redact` (default) — replaces each match with `[REDACTED:<kind>]` in a CLONE
|
|
15
|
-
* of the request before sending, and also redacts secrets echoed back
|
|
16
|
-
* in the response
|
|
17
|
-
* - `block` — throws before the request is sent (the agent's error
|
|
18
|
-
* path surfaces it), so the secret never reaches the provider
|
|
19
|
-
*
|
|
20
|
-
* Safety posture: opt-in — loads inert until
|
|
21
|
-
* `config.extensions['prompt-firewall'].enabled = true`. `redact` is the
|
|
22
|
-
* default so secrets are automatically stripped; switch to `warn` only
|
|
23
|
-
* for detection-mode diagnostics without data loss.
|
|
24
|
-
*
|
|
25
|
-
* Config (`config.extensions['prompt-firewall']`):
|
|
26
|
-
*
|
|
27
|
-
* ```jsonc
|
|
28
|
-
* {
|
|
29
|
-
* "enabled": false,
|
|
30
|
-
* "mode": "redact", // "warn" | "redact" | "block"
|
|
31
|
-
* "scanResponse": true, // redact secrets echoed back (redact mode)
|
|
32
|
-
* "allow": [] // regex source strings to exempt (false positives)
|
|
33
|
-
* }
|
|
34
|
-
* ```
|
|
35
|
-
*
|
|
36
|
-
* Tools:
|
|
37
|
-
* - `prompt_firewall_status` — mode, pattern names, detection counters
|
|
38
|
-
*
|
|
39
|
-
* @public
|
|
40
|
-
*/
|
|
41
1
|
import type { Plugin } from '@wrongstack/core/types';
|
|
42
2
|
/**
|
|
43
3
|
* Legacy `kind` names for the shapes this plugin already reported, so the
|
|
@@ -50,10 +10,52 @@ export interface Detection {
|
|
|
50
10
|
kind: string;
|
|
51
11
|
count: number;
|
|
52
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* A pattern excluded from a scan pass. `redos-timeout` = the pattern blew
|
|
15
|
+
* its wall-clock budget on the guarded probe and was skipped (fail-open
|
|
16
|
+
* with a visible counter — this surface trades blocking for availability;
|
|
17
|
+
* `secret-scanner` is the fail-closed tool-side gate).
|
|
18
|
+
*/
|
|
19
|
+
export interface ScanSkip {
|
|
20
|
+
kind: string;
|
|
21
|
+
reason: 'redos-timeout';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Mutable deadline shared across one scan pass. `tripped` collects the
|
|
25
|
+
* kinds that blew the budget mid-pass; the caller merges them into the
|
|
26
|
+
* visible skip surface (status + counters + warn log).
|
|
27
|
+
*/
|
|
28
|
+
interface ScanDeadline {
|
|
29
|
+
deadline: number;
|
|
30
|
+
tripped: Set<string>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Hard bound on any single synchronous regex input in a scan pass. The
|
|
34
|
+
* structural guarantee of #371: even a growth-loop re-measure never hands
|
|
35
|
+
* a regex the full unbounded leaf.
|
|
36
|
+
*/
|
|
37
|
+
export declare const SCAN_WINDOW_LIMIT: number;
|
|
53
38
|
/** Detect secret matches in text. Returns per-kind counts (no values). */
|
|
54
39
|
export declare function detectSecrets(text: string, allow: RegExp[]): Detection[];
|
|
55
40
|
/** Redact secret matches in text, replacing each with `[REDACTED:<kind>]`. */
|
|
56
|
-
export declare function redactSecrets(text: string, allow: RegExp[]): {
|
|
41
|
+
export declare function redactSecrets(text: string, allow: RegExp[], deadline?: ScanDeadline): {
|
|
42
|
+
text: string;
|
|
43
|
+
redactions: number;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Detect secret matches, skipping patterns that blow the ReDoS budget on
|
|
47
|
+
* the guarded probe. The skip set is returned so callers can surface it
|
|
48
|
+
* (status counters / logs) instead of silently trusting the result.
|
|
49
|
+
*/
|
|
50
|
+
export declare function detectSecretsGuarded(text: string, allow: RegExp[]): Promise<{
|
|
51
|
+
detections: Detection[];
|
|
52
|
+
skipped: ScanSkip[];
|
|
53
|
+
}>;
|
|
54
|
+
/**
|
|
55
|
+
* Redact secret matches, skipping the given timed-out patterns (reuse the
|
|
56
|
+
* set from `detectSecretsGuarded` so a request pays the probe once).
|
|
57
|
+
*/
|
|
58
|
+
export declare function redactSecretsGuarded(text: string, allow: RegExp[], skip: ReadonlySet<string>, deadline?: ScanDeadline): {
|
|
57
59
|
text: string;
|
|
58
60
|
redactions: number;
|
|
59
61
|
};
|
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
|
|
@@ -246,32 +249,161 @@ var PATTERNS = [
|
|
|
246
249
|
})),
|
|
247
250
|
...EXTRA_PATTERNS
|
|
248
251
|
];
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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);
|
|
254
294
|
while (m !== null) {
|
|
295
|
+
if (deadline && performance.now() > deadline.deadline) {
|
|
296
|
+
deadline.tripped.add(p.kind);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
255
299
|
const matched = m[0];
|
|
256
|
-
if (
|
|
257
|
-
|
|
300
|
+
if (matched.length === 0) {
|
|
301
|
+
re.lastIndex += 1;
|
|
302
|
+
m = re.exec(slice);
|
|
303
|
+
continue;
|
|
258
304
|
}
|
|
259
|
-
|
|
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
|
+
}
|
|
317
|
+
}
|
|
318
|
+
m = re.exec(slice);
|
|
319
|
+
}
|
|
320
|
+
acceptLo += SCAN_WINDOW_STRIDE;
|
|
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}]`;
|
|
260
348
|
}
|
|
349
|
+
copied = m.end;
|
|
261
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);
|
|
262
356
|
return [...counts.entries()].map(([kind, count]) => ({ kind, count }));
|
|
263
357
|
}
|
|
264
|
-
function redactSecrets(text, allow) {
|
|
358
|
+
function redactSecrets(text, allow, deadline) {
|
|
359
|
+
const redactions = { n: 0 };
|
|
265
360
|
let out = text;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
272
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);
|
|
384
|
+
}
|
|
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);
|
|
273
405
|
}
|
|
274
|
-
return { text: out, redactions };
|
|
406
|
+
return { text: out, redactions: redactions.n };
|
|
275
407
|
}
|
|
276
408
|
function collectText(request) {
|
|
277
409
|
const parts = [];
|
|
@@ -285,17 +417,27 @@ function collectText(request) {
|
|
|
285
417
|
walk(request["messages"]);
|
|
286
418
|
return parts.join("\n");
|
|
287
419
|
}
|
|
288
|
-
|
|
420
|
+
var RESPONSE_SCAN_BUDGET = 1e6;
|
|
421
|
+
function redactDeep(value, allow, counter, skip, budget, deadline) {
|
|
289
422
|
if (typeof value === "string") {
|
|
290
|
-
|
|
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);
|
|
291
432
|
counter.n += redactions;
|
|
292
433
|
return text;
|
|
293
434
|
}
|
|
294
|
-
if (Array.isArray(value))
|
|
435
|
+
if (Array.isArray(value))
|
|
436
|
+
return value.map((v) => redactDeep(v, allow, counter, skip, budget, deadline));
|
|
295
437
|
if (value && typeof value === "object") {
|
|
296
438
|
const out = {};
|
|
297
439
|
for (const [k, v] of Object.entries(value)) {
|
|
298
|
-
out[k] = redactDeep(v, allow, counter);
|
|
440
|
+
out[k] = redactDeep(v, allow, counter, skip, budget, deadline);
|
|
299
441
|
}
|
|
300
442
|
return out;
|
|
301
443
|
}
|
|
@@ -331,16 +473,33 @@ var state = {
|
|
|
331
473
|
responseRedactions: 0,
|
|
332
474
|
blocked: 0,
|
|
333
475
|
timeoutCount: 0,
|
|
476
|
+
skippedPatterns: [],
|
|
477
|
+
responseTruncated: false,
|
|
334
478
|
byKind: /* @__PURE__ */ new Map(),
|
|
335
479
|
lastDetection: null,
|
|
336
480
|
extensionUnregister: null
|
|
337
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
|
+
}
|
|
338
492
|
var plugin = {
|
|
339
493
|
name: "prompt-firewall",
|
|
340
494
|
version: "0.1.0",
|
|
341
495
|
description: "Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.",
|
|
342
496
|
apiVersion: "^0.1.10",
|
|
343
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.
|
|
344
503
|
defaultConfig: { enabled: false, mode: "redact", scanResponse: true, allow: [] },
|
|
345
504
|
configSchema: {
|
|
346
505
|
type: "object",
|
|
@@ -376,6 +535,8 @@ var plugin = {
|
|
|
376
535
|
state.responseRedactions = 0;
|
|
377
536
|
state.blocked = 0;
|
|
378
537
|
state.timeoutCount = 0;
|
|
538
|
+
state.skippedPatterns = [];
|
|
539
|
+
state.responseTruncated = false;
|
|
379
540
|
state.byKind.clear();
|
|
380
541
|
state.lastDetection = null;
|
|
381
542
|
if (state.extensionUnregister) {
|
|
@@ -399,15 +560,16 @@ var plugin = {
|
|
|
399
560
|
const req = request ?? {};
|
|
400
561
|
state.invocations += 1;
|
|
401
562
|
const requestText = collectText(req);
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
407
569
|
});
|
|
408
|
-
|
|
570
|
+
api.metrics.counter("redos_skips", 1);
|
|
409
571
|
}
|
|
410
|
-
const
|
|
572
|
+
const skipSet = new Set(state.skippedPatterns);
|
|
411
573
|
if (detections.length > 0) {
|
|
412
574
|
state.requestsWithSecrets += 1;
|
|
413
575
|
for (const d of detections) {
|
|
@@ -426,27 +588,39 @@ var plugin = {
|
|
|
426
588
|
}
|
|
427
589
|
if (cfg.mode === "redact") {
|
|
428
590
|
const counter = { n: 0 };
|
|
429
|
-
const
|
|
591
|
+
const deadline = createScanDeadline();
|
|
592
|
+
const redactedReq = redactDeep(req, cfg.allow, counter, skipSet, void 0, deadline);
|
|
430
593
|
state.requestRedactions += counter.n;
|
|
431
594
|
api.metrics.counter("request_redactions", counter.n);
|
|
595
|
+
if (deadline.tripped.size > 0) surfaceScanTrips(api, deadline.tripped);
|
|
432
596
|
const response2 = await inner(_ctx, redactedReq);
|
|
433
|
-
return cfg.scanResponse ? redactResponse(response2, cfg.allow) : response2;
|
|
597
|
+
return cfg.scanResponse ? redactResponse(response2, cfg.allow, skipSet) : response2;
|
|
434
598
|
}
|
|
435
599
|
}
|
|
436
600
|
const response = await inner(_ctx, request);
|
|
437
601
|
if (cfg.mode === "redact" && cfg.scanResponse) {
|
|
438
|
-
return redactResponse(response, cfg.allow);
|
|
602
|
+
return redactResponse(response, cfg.allow, skipSet);
|
|
439
603
|
}
|
|
440
604
|
return response;
|
|
441
605
|
}
|
|
442
606
|
});
|
|
443
607
|
}
|
|
444
|
-
function redactResponse(response, allow) {
|
|
608
|
+
function redactResponse(response, allow, skip) {
|
|
445
609
|
if (!response || typeof response !== "object") return response;
|
|
446
610
|
const counter = { n: 0 };
|
|
447
611
|
const content = response.content;
|
|
448
612
|
if (content === void 0) return response;
|
|
449
|
-
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
|
+
}
|
|
450
624
|
if (counter.n > 0) {
|
|
451
625
|
state.responseRedactions += counter.n;
|
|
452
626
|
api.metrics.counter("response_redactions", counter.n);
|
|
@@ -472,6 +646,8 @@ var plugin = {
|
|
|
472
646
|
mode: cfg.mode,
|
|
473
647
|
scanResponse: cfg.scanResponse,
|
|
474
648
|
patterns: PATTERNS.map((p) => p.kind),
|
|
649
|
+
skippedPatterns: state.skippedPatterns,
|
|
650
|
+
responseTruncated: state.responseTruncated,
|
|
475
651
|
counters: {
|
|
476
652
|
invocations: state.invocations,
|
|
477
653
|
requestsWithSecrets: state.requestsWithSecrets,
|
|
@@ -505,13 +681,17 @@ var plugin = {
|
|
|
505
681
|
requestsWithSecrets: state.requestsWithSecrets,
|
|
506
682
|
requestRedactions: state.requestRedactions,
|
|
507
683
|
responseRedactions: state.responseRedactions,
|
|
508
|
-
blocked: state.blocked
|
|
684
|
+
blocked: state.blocked,
|
|
685
|
+
timeoutCount: state.timeoutCount
|
|
509
686
|
};
|
|
510
687
|
state.invocations = 0;
|
|
511
688
|
state.requestsWithSecrets = 0;
|
|
512
689
|
state.requestRedactions = 0;
|
|
513
690
|
state.responseRedactions = 0;
|
|
514
691
|
state.blocked = 0;
|
|
692
|
+
state.timeoutCount = 0;
|
|
693
|
+
state.skippedPatterns = [];
|
|
694
|
+
state.responseTruncated = false;
|
|
515
695
|
state.byKind.clear();
|
|
516
696
|
state.lastDetection = null;
|
|
517
697
|
api.log.info("prompt-firewall: teardown complete", { final });
|
|
@@ -533,8 +713,11 @@ var plugin = {
|
|
|
533
713
|
var prompt_firewall_default = plugin;
|
|
534
714
|
export {
|
|
535
715
|
KIND_ALIASES,
|
|
716
|
+
SCAN_WINDOW_LIMIT,
|
|
536
717
|
prompt_firewall_default as default,
|
|
537
718
|
detectSecrets,
|
|
719
|
+
detectSecretsGuarded,
|
|
538
720
|
readConfig,
|
|
539
|
-
redactSecrets
|
|
721
|
+
redactSecrets,
|
|
722
|
+
redactSecretsGuarded
|
|
540
723
|
};
|
package/dist/spec-linker.js
CHANGED
|
@@ -55,10 +55,10 @@ var OFFICIAL_PLUGIN_NAMES = [
|
|
|
55
55
|
"notify-hub",
|
|
56
56
|
"changelog-writer",
|
|
57
57
|
"injection-shield",
|
|
58
|
+
"prompt-firewall",
|
|
58
59
|
"llm-cache",
|
|
59
60
|
"model-router",
|
|
60
61
|
"pr-drafter",
|
|
61
|
-
"prompt-firewall",
|
|
62
62
|
"auto-escalate",
|
|
63
63
|
"test-coverage-gate",
|
|
64
64
|
"type-gate",
|