@wrongstack/plugins 0.307.1 → 0.308.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accessibility-auditor/index.d.ts +5 -0
- package/dist/accessibility-auditor.js +67 -11
- 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/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 +22 -1
- package/dist/index.js +509 -206
- 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/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/prompt-firewall/index.d.ts +7 -0
- package/dist/prompt-firewall.js +92 -2
- 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/type-gate.js +33 -3
- package/package.json +3 -3
package/dist/lint-gate.js
CHANGED
|
@@ -221,9 +221,10 @@ async function detectLinter(requested, cwd) {
|
|
|
221
221
|
}
|
|
222
222
|
async function lintContent(content, filePath, linter, timeoutMs, cwd, signal) {
|
|
223
223
|
const ext = filePath.includes(".") ? filePath.slice(filePath.lastIndexOf(".")) : ".ts";
|
|
224
|
-
|
|
225
|
-
const tmpFile = join(tmpDir, `input${ext}`);
|
|
224
|
+
let tmpDir;
|
|
226
225
|
try {
|
|
226
|
+
tmpDir = await mkdtemp(join(tmpdir(), "lint-gate-"));
|
|
227
|
+
const tmpFile = join(tmpDir, `input${ext}`);
|
|
227
228
|
await writeFile(tmpFile, content, "utf-8");
|
|
228
229
|
const fullArgs = [...linter.args, tmpFile];
|
|
229
230
|
const result = await runCommand(linter.cmd, fullArgs, timeoutMs, cwd, signal);
|
|
@@ -234,14 +235,15 @@ async function lintContent(content, filePath, linter, timeoutMs, cwd, signal) {
|
|
|
234
235
|
if (signal.aborted) throw signal.reason;
|
|
235
236
|
return null;
|
|
236
237
|
} finally {
|
|
237
|
-
await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
|
|
238
|
+
if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
|
|
238
239
|
}
|
|
239
240
|
}
|
|
240
241
|
async function lintAndFix(content, filePath, linter, timeoutMs, cwd, signal) {
|
|
241
242
|
const ext = filePath.includes(".") ? filePath.slice(filePath.lastIndexOf(".")) : ".ts";
|
|
242
|
-
|
|
243
|
-
const tmpFile = join(tmpDir, `input${ext}`);
|
|
243
|
+
let tmpDir;
|
|
244
244
|
try {
|
|
245
|
+
tmpDir = await mkdtemp(join(tmpdir(), "lint-gate-fix-"));
|
|
246
|
+
const tmpFile = join(tmpDir, `input${ext}`);
|
|
245
247
|
await writeFile(tmpFile, content, "utf-8");
|
|
246
248
|
const fixArgs = linter.name === "biome" ? [linter.args[0], "check", "--write", tmpFile] : [linter.args[0], "--fix", tmpFile];
|
|
247
249
|
await runCommand(linter.cmd, fixArgs, timeoutMs, cwd, signal);
|
|
@@ -251,7 +253,7 @@ async function lintAndFix(content, filePath, linter, timeoutMs, cwd, signal) {
|
|
|
251
253
|
if (signal.aborted) throw signal.reason;
|
|
252
254
|
return content;
|
|
253
255
|
} finally {
|
|
254
|
-
await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
|
|
256
|
+
if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
259
|
function parseLinterOutput(stdout, linterName) {
|
|
@@ -505,9 +507,9 @@ ${summary}${truncated}`
|
|
|
505
507
|
name: "lint-gate",
|
|
506
508
|
stage: "mutate",
|
|
507
509
|
timeoutMs: Math.max(1e3, cfg.timeoutMs + 1e3),
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
failurePolicy: "
|
|
510
|
+
// Fail closed: a linter crash or timeout must not let unlinted
|
|
511
|
+
// content through in block mode (issue #363).
|
|
512
|
+
failurePolicy: "closed"
|
|
511
513
|
});
|
|
512
514
|
api.tools.register({
|
|
513
515
|
name: "lint_gate_status",
|
|
@@ -579,5 +581,6 @@ ${summary}${truncated}`
|
|
|
579
581
|
};
|
|
580
582
|
var lint_gate_default = plugin;
|
|
581
583
|
export {
|
|
582
|
-
lint_gate_default as default
|
|
584
|
+
lint_gate_default as default,
|
|
585
|
+
resolveLocalLinter
|
|
583
586
|
};
|
|
@@ -2,6 +2,20 @@ export interface WriteTarget {
|
|
|
2
2
|
path: string;
|
|
3
3
|
kind: 'file' | 'scope' | 'deletion-scope';
|
|
4
4
|
}
|
|
5
|
+
/** Default wall-clock budget per glob match (ms). Must clear worker spin-up (~20ms). */
|
|
6
|
+
export declare const GLOB_REDOS_BUDGET_MS = 250;
|
|
7
|
+
export interface GuardedMatchOptions {
|
|
8
|
+
/** Invoked when a match times out — wired to the plugin's redosTimeouts counter. */
|
|
9
|
+
onTimeout?: (info: {
|
|
10
|
+
regex: RegExp;
|
|
11
|
+
input: string;
|
|
12
|
+
budgetMs: number;
|
|
13
|
+
elapsedMs: number;
|
|
14
|
+
}) => void;
|
|
15
|
+
/** Per-regex wall-clock budget. Default GLOB_REDOS_BUDGET_MS. */
|
|
16
|
+
budgetMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function matchesAnyGuarded(path: string, patterns: RegExp[], options?: GuardedMatchOptions): Promise<boolean>;
|
|
5
19
|
/**
|
|
6
20
|
* Compile a glob pattern to a RegExp. Supports `**` (any depth),
|
|
7
21
|
* `*` (within one segment), and `?` (single char). Matching is done
|
|
@@ -27,6 +41,15 @@ export declare function staticPrefix(pattern: string): string;
|
|
|
27
41
|
export declare function hasPartialSegmentWildcard(pattern: string): boolean;
|
|
28
42
|
export declare function globWitness(pattern: string): string;
|
|
29
43
|
export declare function scopesMayOverlap(left: string, right: string): boolean;
|
|
44
|
+
export declare function targetIntersectsScope(target: WriteTarget, patternTexts: string[]): boolean;
|
|
30
45
|
export declare function targetIntersectsPatterns(target: WriteTarget, patternTexts: string[], patterns: RegExp[]): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* ReDoS-guarded variant of `targetIntersectsPatterns` (issue #365 / audit
|
|
48
|
+
* T-02): the direct path match runs through `matchesAnyGuarded` (worker
|
|
49
|
+
* watchdog, fail-closed on timeout); scope-overlap logic stays synchronous
|
|
50
|
+
* because it matches against constructed candidate paths, not the raw
|
|
51
|
+
* hostile input.
|
|
52
|
+
*/
|
|
53
|
+
export declare function targetIntersectsPatternsGuarded(target: WriteTarget, patternTexts: string[], patterns: RegExp[], options?: GuardedMatchOptions): Promise<boolean>;
|
|
31
54
|
export declare function targetFullyAllowed(target: WriteTarget, allowTexts: string[], allowRes: RegExp[]): boolean;
|
|
32
55
|
//# sourceMappingURL=glob.d.ts.map
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import type { Plugin } from '@wrongstack/core/types';
|
|
8
8
|
export { compilePathGlob } from './glob.js';
|
|
9
9
|
export { destructiveTargets } from './shell-targets.js';
|
|
10
|
+
/** True when `path` is a project-local symlink whose real target escaped. */
|
|
11
|
+
export declare function isSymlinkEscape(path: string, cwd?: string): boolean;
|
|
10
12
|
declare const plugin: Plugin;
|
|
11
13
|
export default plugin;
|
|
12
14
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/path-guard.js
CHANGED
|
@@ -1,4 +1,104 @@
|
|
|
1
|
+
// src/runtime/redos-guard.ts
|
|
2
|
+
import { Worker } from "node:worker_threads";
|
|
3
|
+
function withReDoSGuard(re, input, budgetMs = 50, options = {}) {
|
|
4
|
+
const opts = { budgetMs, ...options };
|
|
5
|
+
const start = Date.now();
|
|
6
|
+
const workerSource = buildWorkerSource(re.source, input, re.flags);
|
|
7
|
+
const worker = new Worker(workerSource, {
|
|
8
|
+
eval: true,
|
|
9
|
+
name: `redos-guard:${re.source.slice(0, 32)}`
|
|
10
|
+
});
|
|
11
|
+
return new Promise((resolve3) => {
|
|
12
|
+
let settled = false;
|
|
13
|
+
const onMessage = (msg) => {
|
|
14
|
+
if (settled) return;
|
|
15
|
+
settled = true;
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
worker.terminate().catch(() => {
|
|
18
|
+
});
|
|
19
|
+
if (!msg.ok) {
|
|
20
|
+
resolve3({ timedOut: true, match: null });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
resolve3({ timedOut: false, match: msg.match });
|
|
24
|
+
};
|
|
25
|
+
const onError = () => {
|
|
26
|
+
if (settled) return;
|
|
27
|
+
settled = true;
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
worker.terminate().catch(() => {
|
|
30
|
+
});
|
|
31
|
+
resolve3({ timedOut: true, match: null });
|
|
32
|
+
};
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
const elapsedMs = Date.now() - start;
|
|
37
|
+
worker.terminate().catch(() => {
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
opts.onTimeout?.({
|
|
41
|
+
regex: re,
|
|
42
|
+
input,
|
|
43
|
+
budgetMs: opts.budgetMs,
|
|
44
|
+
elapsedMs
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
resolve3({ timedOut: true, match: null });
|
|
49
|
+
}, opts.budgetMs);
|
|
50
|
+
timer.unref?.();
|
|
51
|
+
worker.on("message", onMessage);
|
|
52
|
+
worker.on("error", onError);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function buildWorkerSource(source, input, flags) {
|
|
56
|
+
const S = JSON.stringify(source);
|
|
57
|
+
const I = JSON.stringify(input);
|
|
58
|
+
const F = JSON.stringify(flags);
|
|
59
|
+
return `
|
|
60
|
+
const { parentPort } = require('node:worker_threads');
|
|
61
|
+
const source = ${S};
|
|
62
|
+
const input = ${I};
|
|
63
|
+
const flags = ${F};
|
|
64
|
+
try {
|
|
65
|
+
const re = new RegExp(source, flags);
|
|
66
|
+
const match = re.exec(input);
|
|
67
|
+
// parentPort.postMessage, NOT bare postMessage: with eval:true
|
|
68
|
+
// workers this Node version does not expose the bare postMessage
|
|
69
|
+
// global \u2014 the worker throws ReferenceError at startup and the
|
|
70
|
+
// host misreads it as a timeout (positive-path regression).
|
|
71
|
+
parentPort.postMessage({ ok: true, match });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
|
|
1
78
|
// src/path-guard/glob.ts
|
|
79
|
+
var GLOB_REDOS_BUDGET_MS = 250;
|
|
80
|
+
function mergeGuardedRegex(patterns) {
|
|
81
|
+
const flags = /* @__PURE__ */ new Set();
|
|
82
|
+
for (const p of patterns) {
|
|
83
|
+
for (const f of p.flags) {
|
|
84
|
+
if (f !== "g" && f !== "y") flags.add(f);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const uniqueFlags = [...flags].join("");
|
|
88
|
+
return new RegExp(patterns.map((p) => p.source).join("|"), uniqueFlags);
|
|
89
|
+
}
|
|
90
|
+
async function matchesAnyGuarded(path, patterns, options = {}) {
|
|
91
|
+
const normalized = normalizePath(path);
|
|
92
|
+
if (patterns.length === 0) return false;
|
|
93
|
+
const result = await withReDoSGuard(
|
|
94
|
+
mergeGuardedRegex(patterns),
|
|
95
|
+
normalized,
|
|
96
|
+
options.budgetMs ?? GLOB_REDOS_BUDGET_MS,
|
|
97
|
+
options.onTimeout ? { onTimeout: options.onTimeout } : {}
|
|
98
|
+
);
|
|
99
|
+
if (result.timedOut) return true;
|
|
100
|
+
return result.match !== null;
|
|
101
|
+
}
|
|
2
102
|
function compilePathGlob(pattern) {
|
|
3
103
|
const normalized = pattern.replace(/\\/g, "/");
|
|
4
104
|
let source = "";
|
|
@@ -89,8 +189,8 @@ function isRootPathScope(path) {
|
|
|
89
189
|
function isDirectoryAmbiguousPath(path) {
|
|
90
190
|
const normalized = normalizePath(path).replace(/\/$/, "");
|
|
91
191
|
if (isRootPathScope(normalized)) return true;
|
|
92
|
-
const
|
|
93
|
-
return path.endsWith("/") ||
|
|
192
|
+
const basename2 = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
193
|
+
return path.endsWith("/") || basename2.length > 0 && !basename2.includes(".");
|
|
94
194
|
}
|
|
95
195
|
function hasConfiguredProtectedDescendant(path, patterns) {
|
|
96
196
|
const normalized = normalizePath(path).replace(/\/$/, "").toLowerCase();
|
|
@@ -133,8 +233,7 @@ function scopesMayOverlap(left, right) {
|
|
|
133
233
|
}
|
|
134
234
|
return hasPartialSegmentWildcard(left) && rightPrefix.startsWith(leftPrefix) || hasPartialSegmentWildcard(right) && leftPrefix.startsWith(rightPrefix);
|
|
135
235
|
}
|
|
136
|
-
function
|
|
137
|
-
if (matchesAny(target.path, patterns)) return true;
|
|
236
|
+
function targetIntersectsScope(target, patternTexts) {
|
|
138
237
|
if (target.kind === "file") return false;
|
|
139
238
|
const normalized = normalizePath(target.path).replace(/\/$/, "");
|
|
140
239
|
if (!isUnresolvedPathScope(normalized)) {
|
|
@@ -146,6 +245,14 @@ function targetIntersectsPatterns(target, patternTexts, patterns) {
|
|
|
146
245
|
}
|
|
147
246
|
return patternTexts.some((pattern) => scopesMayOverlap(normalized, normalizePath(pattern)));
|
|
148
247
|
}
|
|
248
|
+
function targetIntersectsPatterns(target, patternTexts, patterns) {
|
|
249
|
+
if (matchesAny(target.path, patterns)) return true;
|
|
250
|
+
return targetIntersectsScope(target, patternTexts);
|
|
251
|
+
}
|
|
252
|
+
async function targetIntersectsPatternsGuarded(target, patternTexts, patterns, options = {}) {
|
|
253
|
+
if (await matchesAnyGuarded(target.path, patterns, options)) return true;
|
|
254
|
+
return targetIntersectsScope(target, patternTexts);
|
|
255
|
+
}
|
|
149
256
|
function targetFullyAllowed(target, allowTexts, allowRes) {
|
|
150
257
|
if (target.kind === "file") return matchesAny(target.path, allowRes);
|
|
151
258
|
const normalized = normalizePath(target.path).replace(/\/$/, "");
|
|
@@ -462,7 +569,7 @@ function commandRecursivelyDeletes(command) {
|
|
|
462
569
|
)) {
|
|
463
570
|
return true;
|
|
464
571
|
}
|
|
465
|
-
const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(rm|rmdir|del)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
|
|
572
|
+
const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(rm|rmdir|del|rd|Remove-Item)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
|
|
466
573
|
let match = destructive.exec(stripped);
|
|
467
574
|
while (match !== null) {
|
|
468
575
|
const tool = match[1]?.toLowerCase();
|
|
@@ -475,6 +582,8 @@ function commandRecursivelyDeletes(command) {
|
|
|
475
582
|
if (token === "--") break;
|
|
476
583
|
if (tool === "rm") {
|
|
477
584
|
if (token === "--recursive" || /^-[^-]*[rR]/.test(token)) recursive = true;
|
|
585
|
+
} else if (tool === "remove-item") {
|
|
586
|
+
if (/^-Recurse$/i.test(token) || /^-r$/i.test(token)) recursive = true;
|
|
478
587
|
} else if (/^\/[a-z]*s[a-z]*$/i.test(token)) {
|
|
479
588
|
recursive = true;
|
|
480
589
|
}
|
|
@@ -723,7 +832,7 @@ function destructiveTargetsAtDepth(command, depth) {
|
|
|
723
832
|
}
|
|
724
833
|
targets.push(...destructiveTargetsAtDepth(executableBody, depth + 1));
|
|
725
834
|
}
|
|
726
|
-
const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|(?<![$(])\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(?:sudo\s+)?(rm|rmdir|del|unlink|truncate|shred|mv)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
|
|
835
|
+
const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|(?<![$(])\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(?:sudo\s+)?(rm|rmdir|del|rd|Remove-Item|unlink|truncate|shred|mv)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
|
|
727
836
|
let m = destructive.exec(normalizedCommand);
|
|
728
837
|
while (m !== null) {
|
|
729
838
|
if (!tokenIsQuoted(m, m[1] ?? "")) targets.push(...shellArgs(m[2] ?? ""));
|
|
@@ -1211,8 +1320,52 @@ function operationLabel(toolName) {
|
|
|
1211
1320
|
}
|
|
1212
1321
|
|
|
1213
1322
|
// src/path-guard/index.ts
|
|
1323
|
+
import { realpathSync } from "node:fs";
|
|
1324
|
+
import { resolve as resolve2 } from "node:path";
|
|
1325
|
+
|
|
1326
|
+
// src/runtime/index.ts
|
|
1327
|
+
import { basename, extname, isAbsolute, relative, resolve } from "node:path";
|
|
1328
|
+
|
|
1329
|
+
// src/runtime/local-bin.ts
|
|
1330
|
+
import { buildWin32CmdShimInvocation, resolveWin32Command } from "@wrongstack/tools/win32";
|
|
1331
|
+
|
|
1332
|
+
// src/runtime/index.ts
|
|
1333
|
+
var MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
1334
|
+
function hasLeadingDash(arg) {
|
|
1335
|
+
return arg.length > 0 && arg.startsWith("-");
|
|
1336
|
+
}
|
|
1337
|
+
function withinProjectPath(projectRoot, candidate) {
|
|
1338
|
+
if (candidate.length === 0 || candidate.length > 4096) return false;
|
|
1339
|
+
if (hasLeadingDash(candidate)) return false;
|
|
1340
|
+
const resolved = isAbsolute(candidate) ? resolve(candidate) : resolve(projectRoot, candidate);
|
|
1341
|
+
const rel = relative(projectRoot, resolved);
|
|
1342
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
1343
|
+
}
|
|
1344
|
+
function withinProject(p) {
|
|
1345
|
+
const cwd = process.cwd();
|
|
1346
|
+
return withinProjectPath(cwd, p) || relative(cwd, p) === ".";
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// src/path-guard/index.ts
|
|
1350
|
+
function isSymlinkEscape(path, cwd) {
|
|
1351
|
+
if (!withinProject(path)) return false;
|
|
1352
|
+
try {
|
|
1353
|
+
const abs = resolve2(cwd ?? process.cwd(), path);
|
|
1354
|
+
const real = realpathSync(abs);
|
|
1355
|
+
return !withinProject(real);
|
|
1356
|
+
} catch {
|
|
1357
|
+
return false;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1214
1360
|
function createState() {
|
|
1215
|
-
return {
|
|
1361
|
+
return {
|
|
1362
|
+
invocations: 0,
|
|
1363
|
+
blocks: 0,
|
|
1364
|
+
warns: 0,
|
|
1365
|
+
redosTimeouts: 0,
|
|
1366
|
+
lastBlock: null,
|
|
1367
|
+
hookUnregister: null
|
|
1368
|
+
};
|
|
1216
1369
|
}
|
|
1217
1370
|
var states = /* @__PURE__ */ new WeakMap();
|
|
1218
1371
|
var latestState = createState();
|
|
@@ -1307,7 +1460,11 @@ var plugin = {
|
|
|
1307
1460
|
additionalContext: `path-guard (warn mode): ${subject} and this ${operation} would modify it. Double-check this is intentional.`
|
|
1308
1461
|
};
|
|
1309
1462
|
};
|
|
1310
|
-
const
|
|
1463
|
+
const bumpRedos = () => {
|
|
1464
|
+
state.redosTimeouts += 1;
|
|
1465
|
+
api.metrics.counter("redos_timeouts");
|
|
1466
|
+
};
|
|
1467
|
+
const hook = async (input) => {
|
|
1311
1468
|
if (!cfg.enabled) return;
|
|
1312
1469
|
state.invocations += 1;
|
|
1313
1470
|
const toolName = input.toolName ?? "";
|
|
@@ -1328,21 +1485,27 @@ var plugin = {
|
|
|
1328
1485
|
const effectiveCwd = effectiveToolCwd(ti["cwd"], input.cwd);
|
|
1329
1486
|
const recursivelyDeletes = commandRecursivelyDeletes(commandForInspection);
|
|
1330
1487
|
const deletesImplicitScope = commandDeletesImplicitScope(commandForInspection);
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1488
|
+
const shellHits = await Promise.all(
|
|
1489
|
+
shellTargets.map(async (path) => {
|
|
1490
|
+
const target = {
|
|
1491
|
+
path: relativeToInvocationCwd(resolveTargetPath(path, effectiveCwd), input.cwd),
|
|
1492
|
+
kind: isRootPathScope(path) && deletesImplicitScope || isUnresolvedPathScope(path) || recursivelyDeletes && (isDirectoryAmbiguousPath(path) || hasConfiguredProtectedDescendant(path, cfg.protect)) ? "deletion-scope" : "file"
|
|
1493
|
+
};
|
|
1494
|
+
if (targetFullyAllowed(target, cfg.allow, allowRes)) return null;
|
|
1495
|
+
const protectedShellTarget = await targetIntersectsPatternsGuarded(target, cfg.protect, protectRes, { onTimeout: bumpRedos }) || await matchesAnyGuarded(`${target.path.replace(/\/$/, "")}/.path-guard-probe`, protectRes, {
|
|
1496
|
+
onTimeout: bumpRedos
|
|
1497
|
+
});
|
|
1498
|
+
return protectedShellTarget ? target : null;
|
|
1499
|
+
})
|
|
1500
|
+
);
|
|
1501
|
+
const firstProtected = shellHits.find((t) => t !== null);
|
|
1502
|
+
if (firstProtected) {
|
|
1503
|
+
return verdict(
|
|
1504
|
+
firstProtected.path,
|
|
1505
|
+
toolName,
|
|
1506
|
+
"destructive shell command",
|
|
1507
|
+
firstProtected.kind !== "file"
|
|
1508
|
+
);
|
|
1346
1509
|
}
|
|
1347
1510
|
const writes = writesToDisk({ ...input, toolInput: ti });
|
|
1348
1511
|
const hasStructuredTarget = [...PATH_FIELDS, ...PATH_LIST_FIELDS].some((field) => ti[field] !== void 0) || typeof ti["patch"] === "string";
|
|
@@ -1353,11 +1516,21 @@ var plugin = {
|
|
|
1353
1516
|
}
|
|
1354
1517
|
if (!writesToDisk({ ...input, toolInput: ti }) || isReadOnlyInvocation(toolName, ti)) return;
|
|
1355
1518
|
const targets = pathsFromToolInput(ti, toolName, input.cwd);
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1519
|
+
const writeHits = await Promise.all(
|
|
1520
|
+
targets.map(async (target) => {
|
|
1521
|
+
if (target.kind === "file" && isSymlinkEscape(target.path, input.cwd)) return target;
|
|
1522
|
+
if (targetFullyAllowed(target, cfg.allow, allowRes)) return null;
|
|
1523
|
+
return targetIntersectsPatterns(target, cfg.protect, protectRes) ? target : null;
|
|
1524
|
+
})
|
|
1525
|
+
);
|
|
1526
|
+
const firstProtectedWrite = writeHits.find((t) => t !== null);
|
|
1527
|
+
if (firstProtectedWrite) {
|
|
1528
|
+
return verdict(
|
|
1529
|
+
firstProtectedWrite.path,
|
|
1530
|
+
toolName,
|
|
1531
|
+
operationLabel(toolName),
|
|
1532
|
+
firstProtectedWrite.kind !== "file"
|
|
1533
|
+
);
|
|
1361
1534
|
}
|
|
1362
1535
|
return;
|
|
1363
1536
|
};
|
|
@@ -1384,7 +1557,8 @@ var plugin = {
|
|
|
1384
1557
|
counters: {
|
|
1385
1558
|
invocations: state.invocations,
|
|
1386
1559
|
blocks: state.blocks,
|
|
1387
|
-
warns: state.warns
|
|
1560
|
+
warns: state.warns,
|
|
1561
|
+
redosTimeouts: state.redosTimeouts
|
|
1388
1562
|
},
|
|
1389
1563
|
lastBlock: state.lastBlock
|
|
1390
1564
|
};
|
|
@@ -1407,10 +1581,16 @@ var plugin = {
|
|
|
1407
1581
|
}
|
|
1408
1582
|
state.hookUnregister = null;
|
|
1409
1583
|
}
|
|
1410
|
-
const final = {
|
|
1584
|
+
const final = {
|
|
1585
|
+
invocations: state.invocations,
|
|
1586
|
+
blocks: state.blocks,
|
|
1587
|
+
warns: state.warns,
|
|
1588
|
+
redosTimeouts: state.redosTimeouts
|
|
1589
|
+
};
|
|
1411
1590
|
state.invocations = 0;
|
|
1412
1591
|
state.blocks = 0;
|
|
1413
1592
|
state.warns = 0;
|
|
1593
|
+
state.redosTimeouts = 0;
|
|
1414
1594
|
state.lastBlock = null;
|
|
1415
1595
|
states.delete(api);
|
|
1416
1596
|
api.log.info("path-guard: teardown complete", { final });
|
|
@@ -1423,7 +1603,8 @@ var plugin = {
|
|
|
1423
1603
|
counters: {
|
|
1424
1604
|
invocations: state.invocations,
|
|
1425
1605
|
blocks: state.blocks,
|
|
1426
|
-
warns: state.warns
|
|
1606
|
+
warns: state.warns,
|
|
1607
|
+
redosTimeouts: state.redosTimeouts
|
|
1427
1608
|
}
|
|
1428
1609
|
};
|
|
1429
1610
|
}
|
|
@@ -1432,5 +1613,6 @@ var path_guard_default = plugin;
|
|
|
1432
1613
|
export {
|
|
1433
1614
|
compilePathGlob,
|
|
1434
1615
|
path_guard_default as default,
|
|
1435
|
-
destructiveTargets
|
|
1616
|
+
destructiveTargets,
|
|
1617
|
+
isSymlinkEscape
|
|
1436
1618
|
};
|
|
@@ -39,6 +39,13 @@
|
|
|
39
39
|
* @public
|
|
40
40
|
*/
|
|
41
41
|
import type { Plugin } from '@wrongstack/core/types';
|
|
42
|
+
/**
|
|
43
|
+
* Legacy `kind` names for the shapes this plugin already reported, so the
|
|
44
|
+
* canonical `type` ids from the shared table keep their existing spelling
|
|
45
|
+
* in logs, metrics and the status tool. Anything not listed here is
|
|
46
|
+
* reported under its canonical id.
|
|
47
|
+
*/
|
|
48
|
+
export declare const KIND_ALIASES: Readonly<Record<string, string>>;
|
|
42
49
|
export interface Detection {
|
|
43
50
|
kind: string;
|
|
44
51
|
count: number;
|
package/dist/prompt-firewall.js
CHANGED
|
@@ -130,6 +130,83 @@ function cloneCredentialPatterns() {
|
|
|
130
130
|
}));
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// src/runtime/redos-guard.ts
|
|
134
|
+
import { Worker } from "node:worker_threads";
|
|
135
|
+
function withReDoSGuard(re, input, budgetMs = 50, options = {}) {
|
|
136
|
+
const opts = { budgetMs, ...options };
|
|
137
|
+
const start = Date.now();
|
|
138
|
+
const workerSource = buildWorkerSource(re.source, input, re.flags);
|
|
139
|
+
const worker = new Worker(workerSource, {
|
|
140
|
+
eval: true,
|
|
141
|
+
name: `redos-guard:${re.source.slice(0, 32)}`
|
|
142
|
+
});
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
let settled = false;
|
|
145
|
+
const onMessage = (msg) => {
|
|
146
|
+
if (settled) return;
|
|
147
|
+
settled = true;
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
worker.terminate().catch(() => {
|
|
150
|
+
});
|
|
151
|
+
if (!msg.ok) {
|
|
152
|
+
resolve({ timedOut: true, match: null });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
resolve({ timedOut: false, match: msg.match });
|
|
156
|
+
};
|
|
157
|
+
const onError = () => {
|
|
158
|
+
if (settled) return;
|
|
159
|
+
settled = true;
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
worker.terminate().catch(() => {
|
|
162
|
+
});
|
|
163
|
+
resolve({ timedOut: true, match: null });
|
|
164
|
+
};
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
if (settled) return;
|
|
167
|
+
settled = true;
|
|
168
|
+
const elapsedMs = Date.now() - start;
|
|
169
|
+
worker.terminate().catch(() => {
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
opts.onTimeout?.({
|
|
173
|
+
regex: re,
|
|
174
|
+
input,
|
|
175
|
+
budgetMs: opts.budgetMs,
|
|
176
|
+
elapsedMs
|
|
177
|
+
});
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
resolve({ timedOut: true, match: null });
|
|
181
|
+
}, opts.budgetMs);
|
|
182
|
+
timer.unref?.();
|
|
183
|
+
worker.on("message", onMessage);
|
|
184
|
+
worker.on("error", onError);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function buildWorkerSource(source, input, flags) {
|
|
188
|
+
const S = JSON.stringify(source);
|
|
189
|
+
const I = JSON.stringify(input);
|
|
190
|
+
const F = JSON.stringify(flags);
|
|
191
|
+
return `
|
|
192
|
+
const { parentPort } = require('node:worker_threads');
|
|
193
|
+
const source = ${S};
|
|
194
|
+
const input = ${I};
|
|
195
|
+
const flags = ${F};
|
|
196
|
+
try {
|
|
197
|
+
const re = new RegExp(source, flags);
|
|
198
|
+
const match = re.exec(input);
|
|
199
|
+
// parentPort.postMessage, NOT bare postMessage: with eval:true
|
|
200
|
+
// workers this Node version does not expose the bare postMessage
|
|
201
|
+
// global \u2014 the worker throws ReferenceError at startup and the
|
|
202
|
+
// host misreads it as a timeout (positive-path regression).
|
|
203
|
+
parentPort.postMessage({ ok: true, match });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
133
210
|
// src/prompt-firewall/index.ts
|
|
134
211
|
var KIND_ALIASES = {
|
|
135
212
|
aws_access_key: "aws-access-key",
|
|
@@ -253,6 +330,7 @@ var state = {
|
|
|
253
330
|
requestRedactions: 0,
|
|
254
331
|
responseRedactions: 0,
|
|
255
332
|
blocked: 0,
|
|
333
|
+
timeoutCount: 0,
|
|
256
334
|
byKind: /* @__PURE__ */ new Map(),
|
|
257
335
|
lastDetection: null,
|
|
258
336
|
extensionUnregister: null
|
|
@@ -297,6 +375,7 @@ var plugin = {
|
|
|
297
375
|
state.requestRedactions = 0;
|
|
298
376
|
state.responseRedactions = 0;
|
|
299
377
|
state.blocked = 0;
|
|
378
|
+
state.timeoutCount = 0;
|
|
300
379
|
state.byKind.clear();
|
|
301
380
|
state.lastDetection = null;
|
|
302
381
|
if (state.extensionUnregister) {
|
|
@@ -319,7 +398,16 @@ var plugin = {
|
|
|
319
398
|
async wrapProviderRunner(_ctx, request, inner) {
|
|
320
399
|
const req = request ?? {};
|
|
321
400
|
state.invocations += 1;
|
|
322
|
-
const
|
|
401
|
+
const requestText = collectText(req);
|
|
402
|
+
for (const extra of EXTRA_PATTERNS) {
|
|
403
|
+
const guarded = await withReDoSGuard(extra.re, requestText, 250, {
|
|
404
|
+
onTimeout: () => {
|
|
405
|
+
state.timeoutCount += 1;
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
if (guarded.timedOut) break;
|
|
409
|
+
}
|
|
410
|
+
const detections = detectSecrets(requestText, cfg.allow);
|
|
323
411
|
if (detections.length > 0) {
|
|
324
412
|
state.requestsWithSecrets += 1;
|
|
325
413
|
for (const d of detections) {
|
|
@@ -389,7 +477,8 @@ var plugin = {
|
|
|
389
477
|
requestsWithSecrets: state.requestsWithSecrets,
|
|
390
478
|
requestRedactions: state.requestRedactions,
|
|
391
479
|
responseRedactions: state.responseRedactions,
|
|
392
|
-
blocked: state.blocked
|
|
480
|
+
blocked: state.blocked,
|
|
481
|
+
timeoutCount: state.timeoutCount
|
|
393
482
|
},
|
|
394
483
|
byKind: Object.fromEntries(state.byKind),
|
|
395
484
|
lastDetection: state.lastDetection
|
|
@@ -443,6 +532,7 @@ var plugin = {
|
|
|
443
532
|
};
|
|
444
533
|
var prompt_firewall_default = plugin;
|
|
445
534
|
export {
|
|
535
|
+
KIND_ALIASES,
|
|
446
536
|
prompt_firewall_default as default,
|
|
447
537
|
detectSecrets,
|
|
448
538
|
readConfig,
|
|
@@ -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';
|