dsh-permission-rules 0.5.6 → 0.6.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/CHANGELOG.md +12 -0
- package/README.es.md +4 -1
- package/README.hi.md +4 -1
- package/README.md +4 -1
- package/README.pt.md +4 -1
- package/README.zh.md +4 -1
- package/docs/rules-format.en.md +37 -0
- package/docs/rules-format.md +38 -1
- package/docs/rules-format.schema.json +29 -0
- package/lib/index.js +404 -29
- package/lib/types/builtin-rules.d.ts +26 -0
- package/lib/types/builtin-rules.d.ts.map +1 -0
- package/lib/types/config.d.ts +16 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/prose.d.ts +1 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/rules.d.ts +62 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +31 -6
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/shell.d.ts +38 -0
- package/lib/types/shell.d.ts.map +1 -0
- package/package.json +2 -1
- package/rules/builtin-high-risk.yaml +155 -0
- package/src/builtin-rules.ts +34 -0
- package/src/config.ts +32 -0
- package/src/index.ts +2 -0
- package/src/prose.ts +6 -5
- package/src/rules.ts +172 -17
- package/src/runtime.ts +85 -12
- package/src/shell.ts +187 -0
package/lib/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
5
|
import chokidar from "chokidar";
|
|
5
6
|
import z from "@deepseek-ai/schemastery";
|
|
6
7
|
import { parse } from "yaml";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
7
9
|
import { createServer, request } from "node:http";
|
|
8
10
|
import { request as request$1 } from "node:https";
|
|
9
11
|
import { connect } from "node:net";
|
|
@@ -291,6 +293,154 @@ function literalPrefixOf(x, y) {
|
|
|
291
293
|
return x.slice(0, min) === y.slice(0, min);
|
|
292
294
|
}
|
|
293
295
|
//#endregion
|
|
296
|
+
//#region src/shell.ts
|
|
297
|
+
/**
|
|
298
|
+
* Decompose one shell command line into its simple commands and, within
|
|
299
|
+
* each, its command word, argument tokens, and redirect targets. Empty
|
|
300
|
+
* input yields an empty decomposition.
|
|
301
|
+
* @param text - the raw command line.
|
|
302
|
+
* @returns the decomposition.
|
|
303
|
+
*/
|
|
304
|
+
function decomposeShellCommand(text) {
|
|
305
|
+
const commands = [];
|
|
306
|
+
let words = [];
|
|
307
|
+
let buffer = "";
|
|
308
|
+
let started = false;
|
|
309
|
+
let redirect = false;
|
|
310
|
+
const flush = () => {
|
|
311
|
+
if (!started) return;
|
|
312
|
+
words.push({
|
|
313
|
+
text: buffer,
|
|
314
|
+
redirect
|
|
315
|
+
});
|
|
316
|
+
buffer = "";
|
|
317
|
+
started = false;
|
|
318
|
+
redirect = false;
|
|
319
|
+
};
|
|
320
|
+
const endCommand = () => {
|
|
321
|
+
flush();
|
|
322
|
+
if (words.length === 0) return;
|
|
323
|
+
const commandIndex = words.findIndex((word) => !word.redirect);
|
|
324
|
+
const command = commandIndex < 0 ? "" : words[commandIndex]?.text ?? "";
|
|
325
|
+
const args = [];
|
|
326
|
+
const redirects = [];
|
|
327
|
+
words.forEach((word, index) => {
|
|
328
|
+
if (index === commandIndex) return;
|
|
329
|
+
if (word.redirect) redirects.push(word.text);
|
|
330
|
+
else args.push(word.text);
|
|
331
|
+
});
|
|
332
|
+
commands.push({
|
|
333
|
+
command,
|
|
334
|
+
args,
|
|
335
|
+
redirects
|
|
336
|
+
});
|
|
337
|
+
words = [];
|
|
338
|
+
};
|
|
339
|
+
const endWordBeforeRedirect = () => {
|
|
340
|
+
if (started && /^\d+$/.test(buffer)) {
|
|
341
|
+
buffer = "";
|
|
342
|
+
started = false;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
flush();
|
|
346
|
+
};
|
|
347
|
+
let i = 0;
|
|
348
|
+
while (i < text.length) {
|
|
349
|
+
const ch = text[i];
|
|
350
|
+
if (ch === "\\") {
|
|
351
|
+
const next = text[i + 1];
|
|
352
|
+
buffer += next ?? "\\";
|
|
353
|
+
started = true;
|
|
354
|
+
i += 2;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (ch === "'") {
|
|
358
|
+
started = true;
|
|
359
|
+
i += 1;
|
|
360
|
+
while (i < text.length && text[i] !== "'") {
|
|
361
|
+
buffer += text[i];
|
|
362
|
+
i += 1;
|
|
363
|
+
}
|
|
364
|
+
i += 1;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (ch === "\"") {
|
|
368
|
+
started = true;
|
|
369
|
+
i += 1;
|
|
370
|
+
while (i < text.length && text[i] !== "\"") {
|
|
371
|
+
const next = text[i + 1];
|
|
372
|
+
if (text[i] === "\\" && (next === "\"" || next === "\\" || next === "$" || next === "`")) {
|
|
373
|
+
buffer += next;
|
|
374
|
+
i += 2;
|
|
375
|
+
} else {
|
|
376
|
+
buffer += text[i];
|
|
377
|
+
i += 1;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
i += 1;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (ch === "\n") {
|
|
384
|
+
flush();
|
|
385
|
+
endCommand();
|
|
386
|
+
i += 1;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (ch === " " || ch === " ") {
|
|
390
|
+
flush();
|
|
391
|
+
i += 1;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (ch === "|") {
|
|
395
|
+
flush();
|
|
396
|
+
i += text[i + 1] === "|" ? 2 : 1;
|
|
397
|
+
endCommand();
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (ch === ";") {
|
|
401
|
+
flush();
|
|
402
|
+
endCommand();
|
|
403
|
+
i += 1;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (ch === "&") {
|
|
407
|
+
flush();
|
|
408
|
+
const next = text[i + 1];
|
|
409
|
+
if (next === "&") {
|
|
410
|
+
i += 2;
|
|
411
|
+
endCommand();
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (next === ">") {
|
|
415
|
+
i += text[i + 2] === ">" ? 3 : 2;
|
|
416
|
+
redirect = true;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
i += 1;
|
|
420
|
+
endCommand();
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (ch === ">") {
|
|
424
|
+
endWordBeforeRedirect();
|
|
425
|
+
i += text[i + 1] === ">" ? 2 : 1;
|
|
426
|
+
redirect = true;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (ch === "<") {
|
|
430
|
+
endWordBeforeRedirect();
|
|
431
|
+
if (text[i + 1] === "<") i += text[i + 2] === "<" ? 3 : 2;
|
|
432
|
+
else i += 1;
|
|
433
|
+
redirect = true;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
buffer += ch;
|
|
437
|
+
started = true;
|
|
438
|
+
i += 1;
|
|
439
|
+
}
|
|
440
|
+
endCommand();
|
|
441
|
+
return { commands };
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
294
444
|
//#region src/rules.ts
|
|
295
445
|
/**
|
|
296
446
|
* The pure rule vocabulary: parsing a `.dsh/rules.yaml` document into a
|
|
@@ -386,8 +536,41 @@ function parseRulesDocument(text) {
|
|
|
386
536
|
*/
|
|
387
537
|
function compileRules(doc, options) {
|
|
388
538
|
if (doc.rules.length > options.maxRules) throw new RuleError(`rule count ${doc.rules.length} exceeds maxRules ${options.maxRules}`);
|
|
539
|
+
return mergeCompiledRules([compileSourceRules(doc, options)], options);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Compile one parsed document into per-source compiled rules. The returned
|
|
543
|
+
* rules carry source-local `index` values and `sourceIndex` 0 — the
|
|
544
|
+
* cacheable unit a runtime can reuse across chains (keyed by source path +
|
|
545
|
+
* content hash). The total-count cap is NOT applied here; it bounds the
|
|
546
|
+
* merged chain in {@link mergeCompiledRules}.
|
|
547
|
+
* @param doc - the parsed document.
|
|
548
|
+
* @param options - compile options.
|
|
549
|
+
* @returns the compiled rules of one source.
|
|
550
|
+
*/
|
|
551
|
+
function compileSourceRules(doc, options) {
|
|
552
|
+
return doc.rules.map((entry, index) => compileRule(entry, index, 0, options));
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Merge per-source compiled rule lists into one chain, assigning the
|
|
556
|
+
* global 0-based `index` and per-list `sourceIndex` attribution. The TOTAL
|
|
557
|
+
* rule count is capped by `maxRules`.
|
|
558
|
+
* @param sources - one compiled rule list per source file, nearest first.
|
|
559
|
+
* @param options - compile options (the cap).
|
|
560
|
+
* @returns the merged ruleset.
|
|
561
|
+
*/
|
|
562
|
+
function mergeCompiledRules(sources, options) {
|
|
563
|
+
const merged = [];
|
|
564
|
+
sources.forEach((list, sourceIndex) => {
|
|
565
|
+
for (const rule of list) merged.push({
|
|
566
|
+
...rule,
|
|
567
|
+
index: merged.length,
|
|
568
|
+
sourceIndex
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
if (merged.length > options.maxRules) throw new RuleError(`rule count ${merged.length} exceeds maxRules ${options.maxRules}`);
|
|
389
572
|
return {
|
|
390
|
-
rules:
|
|
573
|
+
rules: merged,
|
|
391
574
|
caseInsensitivePaths: options.caseInsensitivePaths
|
|
392
575
|
};
|
|
393
576
|
}
|
|
@@ -403,17 +586,8 @@ function compileRules(doc, options) {
|
|
|
403
586
|
*/
|
|
404
587
|
function compileRulesChain(entries, options) {
|
|
405
588
|
const sources = entries.map((entry) => entry.path);
|
|
406
|
-
const merged = [];
|
|
407
|
-
for (const entry of entries) {
|
|
408
|
-
const doc = parseRulesDocument(entry.text);
|
|
409
|
-
for (const rule of doc.rules) merged.push(compileRule(rule, merged.length, sources.indexOf(entry.path), options));
|
|
410
|
-
}
|
|
411
|
-
if (merged.length > options.maxRules) throw new RuleError(`rule count ${merged.length} exceeds maxRules ${options.maxRules}`);
|
|
412
589
|
return {
|
|
413
|
-
ruleset:
|
|
414
|
-
rules: merged,
|
|
415
|
-
caseInsensitivePaths: options.caseInsensitivePaths
|
|
416
|
-
},
|
|
590
|
+
ruleset: mergeCompiledRules(entries.map((entry) => compileSourceRules(parseRulesDocument(entry.text), options)), options),
|
|
417
591
|
sources
|
|
418
592
|
};
|
|
419
593
|
}
|
|
@@ -426,6 +600,7 @@ function compileRule(entry, index, sourceIndex, options) {
|
|
|
426
600
|
const paths = entry.match.paths.map((pattern) => compilePathPattern(pattern, options));
|
|
427
601
|
const env = /* @__PURE__ */ new Map();
|
|
428
602
|
for (const [key, patterns] of Object.entries(entry.match.when.env)) env.set(key, patterns.map((pattern) => compileValuePattern(pattern, options.patternMode, options.maxGlobStars)));
|
|
603
|
+
const argv = entry.match.argv === void 0 ? void 0 : compileArgv(entry.match.argv, options);
|
|
429
604
|
return {
|
|
430
605
|
index,
|
|
431
606
|
sourceIndex,
|
|
@@ -443,10 +618,20 @@ function compileRule(entry, index, sourceIndex, options) {
|
|
|
443
618
|
env,
|
|
444
619
|
platform: entry.match.when.platform
|
|
445
620
|
} },
|
|
621
|
+
...argv === void 0 ? {} : { argv },
|
|
446
622
|
...entry.match.network === void 0 ? {} : { network: compileNetwork(entry.match.network, options.maxGlobStars) },
|
|
447
623
|
source: entry
|
|
448
624
|
};
|
|
449
625
|
}
|
|
626
|
+
/** Compile the `argv` block of one rule; pattern errors throw at load. */
|
|
627
|
+
function compileArgv(doc, options) {
|
|
628
|
+
return {
|
|
629
|
+
command: doc.command.map((pattern) => compileToolPattern(pattern, options.maxGlobStars)),
|
|
630
|
+
args: compileParamPatterns("argv.args", doc.args, options),
|
|
631
|
+
anyArg: compileParamPatterns("argv.anyArg", doc.anyArg, options),
|
|
632
|
+
pipeline: doc.pipeline.map((pattern) => compileToolPattern(pattern, options.maxGlobStars))
|
|
633
|
+
};
|
|
634
|
+
}
|
|
450
635
|
/**
|
|
451
636
|
* Evaluate rules in order against one pending tool call. First match wins;
|
|
452
637
|
* no match returns `undefined` (passthrough). Pure and synchronous — this
|
|
@@ -468,6 +653,7 @@ function matchRules(ruleset, toolName, args, cwd, context = {}) {
|
|
|
468
653
|
if (!matchWhen(rule, context)) continue;
|
|
469
654
|
if (rule.params.size > 0 && (argRecord === void 0 || !matchParams(rule, argRecord))) continue;
|
|
470
655
|
if (rule.absent.length > 0 && !matchAbsent(rule, argRecord)) continue;
|
|
656
|
+
if (rule.argv !== void 0 && (argRecord === void 0 || !matchArgv(rule, argRecord))) continue;
|
|
471
657
|
if (rule.paths.length > 0 && (argRecord === void 0 || !matchPaths(rule, argRecord, cwd, ruleset.caseInsensitivePaths))) continue;
|
|
472
658
|
if (rule.network !== void 0 && (argRecord === void 0 || !matchNetwork(rule, argRecord))) continue;
|
|
473
659
|
return {
|
|
@@ -499,7 +685,7 @@ function findUnreachableRules(ruleset) {
|
|
|
499
685
|
}
|
|
500
686
|
/** Whether every match dimension of one rule is empty. */
|
|
501
687
|
function isCatchAll(rule) {
|
|
502
|
-
return rule.tools.length === 0 && rule.agents.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === void 0 && rule.network === void 0;
|
|
688
|
+
return rule.tools.length === 0 && rule.agents.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === void 0 && rule.argv === void 0 && rule.network === void 0;
|
|
503
689
|
}
|
|
504
690
|
/**
|
|
505
691
|
* One-line human summary of a compiled rule, for `/rules` output. The
|
|
@@ -521,6 +707,15 @@ function describeRule(rule, tokens, source) {
|
|
|
521
707
|
if (params.length > 0) parts.push(`${tokens.params}:${params}`);
|
|
522
708
|
if (rule.source.match.paths.length > 0) parts.push(`${tokens.paths}:${rule.source.match.paths.join(",")}`);
|
|
523
709
|
if (rule.source.match.absent.length > 0) parts.push(`${tokens.absent}:${rule.source.match.absent.join(",")}`);
|
|
710
|
+
const argvDoc = rule.source.match.argv;
|
|
711
|
+
if (argvDoc !== void 0) {
|
|
712
|
+
const argvParts = [];
|
|
713
|
+
if (argvDoc.command.length > 0) argvParts.push(`command=${argvDoc.command.join(",")}`);
|
|
714
|
+
if (argvDoc.args.length > 0) argvParts.push(`args=${argvDoc.args.join(",")}`);
|
|
715
|
+
if (argvDoc.anyArg.length > 0) argvParts.push(`anyArg=${argvDoc.anyArg.join(",")}`);
|
|
716
|
+
if (argvDoc.pipeline.length > 0) argvParts.push(`pipeline=${argvDoc.pipeline.join(",")}`);
|
|
717
|
+
parts.push(`${tokens.argv}:${argvParts.join(" ")}`);
|
|
718
|
+
}
|
|
524
719
|
const whenParts = Object.entries(rule.source.match.when.env).map(([key, patterns]) => `${key}=${patterns.join("|")}`).join(" ");
|
|
525
720
|
const platformPart = rule.source.match.when.platform.length > 0 ? `${tokens.platform}:${rule.source.match.when.platform.join(",")}` : "";
|
|
526
721
|
if (whenParts.length > 0 || platformPart.length > 0) parts.push(`${tokens.when}:${whenParts}${whenParts.length > 0 && platformPart.length > 0 ? " " : ""}${platformPart}`);
|
|
@@ -577,6 +772,50 @@ function matchAbsent(rule, argRecord) {
|
|
|
577
772
|
for (const key of rule.absent) if (argRecord !== void 0 && argRecord[key] !== void 0) return false;
|
|
578
773
|
return true;
|
|
579
774
|
}
|
|
775
|
+
/**
|
|
776
|
+
* Whether a rule's `argv` dimension holds: at least one command-string
|
|
777
|
+
* argument must decompose into simple commands satisfying EVERY listed
|
|
778
|
+
* field. `command` matches any simple-command word; `pipeline` matches the
|
|
779
|
+
* command words joined by `|` (e.g. `curl|sh`); `args` requires every
|
|
780
|
+
* positive pattern to match some token and no negative to match any;
|
|
781
|
+
* `anyArg` requires some token to match a positive pattern (when one is
|
|
782
|
+
* listed) and no negative to match any. Argument tokens include redirect
|
|
783
|
+
* targets, so `> /etc/passwd` feeds the same token set as an argument.
|
|
784
|
+
*/
|
|
785
|
+
function matchArgv(rule, args) {
|
|
786
|
+
const argv = rule.argv;
|
|
787
|
+
if (argv === void 0) return true;
|
|
788
|
+
const commands = extractCommandCandidates(args).flatMap((text) => decomposeShellCommand(text).commands);
|
|
789
|
+
if (commands.length === 0) return false;
|
|
790
|
+
if (argv.command.length > 0 && !commands.some((command) => argv.command.some((regex) => regex.test(command.command)))) return false;
|
|
791
|
+
if (argv.pipeline.length > 0 && !argv.pipeline.some((regex) => regex.test(commands.map((command) => command.command).join("|")))) return false;
|
|
792
|
+
const tokens = commands.flatMap((command) => [...command.args, ...command.redirects]);
|
|
793
|
+
if (argv.args.positive.length > 0) {
|
|
794
|
+
for (const pattern of argv.args.positive) if (!tokens.some((token) => pattern.test(token))) return false;
|
|
795
|
+
}
|
|
796
|
+
if (argv.args.negative.length > 0 && tokens.some((token) => argv.args.negative.some((regex) => regex.test(token)))) return false;
|
|
797
|
+
if (argv.anyArg.positive.length > 0 && !tokens.some((token) => argv.anyArg.positive.some((regex) => regex.test(token)))) return false;
|
|
798
|
+
if (argv.anyArg.negative.length > 0 && tokens.some((token) => argv.anyArg.negative.some((regex) => regex.test(token)))) return false;
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
/** Collect command-string candidates from tool arguments (the documented command keys, any depth). */
|
|
802
|
+
function extractCommandCandidates(args) {
|
|
803
|
+
const candidates = [];
|
|
804
|
+
const walk = (node, depth) => {
|
|
805
|
+
if (depth > MAX_ARGUMENT_DEPTH) return;
|
|
806
|
+
if (Array.isArray(node)) {
|
|
807
|
+
for (const element of node) walk(element, depth + 1);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (typeof node !== "object" || node === null) return;
|
|
811
|
+
for (const [key, value] of Object.entries(node)) {
|
|
812
|
+
if (COMMAND_CANDIDATE_KEYS.includes(key) && typeof value === "string") candidates.push(value);
|
|
813
|
+
walk(value, depth + 1);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
walk(args, 0);
|
|
817
|
+
return candidates;
|
|
818
|
+
}
|
|
580
819
|
/** Whether a rule's when dimension holds for the host facts. */
|
|
581
820
|
function matchWhen(rule, context) {
|
|
582
821
|
const when = rule.when;
|
|
@@ -681,8 +920,8 @@ function parseMatch(raw, at) {
|
|
|
681
920
|
}
|
|
682
921
|
};
|
|
683
922
|
const record = asRecord(raw, `${at}.match`);
|
|
684
|
-
const unknownFields = Object.keys(record).filter((key) => key !== "tools" && key !== "agents" && key !== "params" && key !== "paths" && key !== "absent" && key !== "when" && key !== "network");
|
|
685
|
-
if (unknownFields.length > 0) throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: tools, agents, params, paths, absent, when, network)`);
|
|
923
|
+
const unknownFields = Object.keys(record).filter((key) => key !== "tools" && key !== "agents" && key !== "params" && key !== "paths" && key !== "absent" && key !== "when" && key !== "argv" && key !== "network");
|
|
924
|
+
if (unknownFields.length > 0) throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: tools, agents, params, paths, absent, when, argv, network)`);
|
|
686
925
|
return {
|
|
687
926
|
tools: parseStringList(record["tools"], `${at}.match.tools`),
|
|
688
927
|
agents: parseStringList(record["agents"], `${at}.match.agents`),
|
|
@@ -690,9 +929,31 @@ function parseMatch(raw, at) {
|
|
|
690
929
|
paths: parseStringList(record["paths"], `${at}.match.paths`),
|
|
691
930
|
absent: parseStringList(record["absent"], `${at}.match.absent`),
|
|
692
931
|
when: parseWhen(record["when"], `${at}.match.when`),
|
|
932
|
+
...record["argv"] === void 0 ? {} : { argv: parseArgv(record["argv"], `${at}.match.argv`) },
|
|
693
933
|
...record["network"] === void 0 ? {} : { network: parseNetwork(record["network"], `${at}.match.network`) }
|
|
694
934
|
};
|
|
695
935
|
}
|
|
936
|
+
/**
|
|
937
|
+
* Validate the `argv` block of one rule: closed field vocabulary plus the
|
|
938
|
+
* same pattern-shape rules as every other pattern list. A block with every
|
|
939
|
+
* field empty is rejected — an `argv` rule must name at least one field.
|
|
940
|
+
*/
|
|
941
|
+
function parseArgv(raw, at) {
|
|
942
|
+
const record = asRecord(raw, at);
|
|
943
|
+
const unknownFields = Object.keys(record).filter((key) => key !== "command" && key !== "args" && key !== "anyArg" && key !== "pipeline");
|
|
944
|
+
if (unknownFields.length > 0) throw new RuleError(`${at}: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: command, args, anyArg, pipeline)`);
|
|
945
|
+
const command = parseStringList(record["command"], `${at}.command`);
|
|
946
|
+
const args = parsePatternValues(record["args"], `${at}.args`);
|
|
947
|
+
const anyArg = parsePatternValues(record["anyArg"], `${at}.anyArg`);
|
|
948
|
+
const pipeline = parseStringList(record["pipeline"], `${at}.pipeline`);
|
|
949
|
+
if (command.length === 0 && args.length === 0 && anyArg.length === 0 && pipeline.length === 0) throw new RuleError(`${at}: an argv block must name at least one of command, args, anyArg, pipeline (drop the block for a tool-level rule)`);
|
|
950
|
+
return {
|
|
951
|
+
command,
|
|
952
|
+
args,
|
|
953
|
+
anyArg,
|
|
954
|
+
pipeline
|
|
955
|
+
};
|
|
956
|
+
}
|
|
696
957
|
/** Validate the params map: non-empty string keys → one pattern or a pattern list. */
|
|
697
958
|
function parseParams(raw, at) {
|
|
698
959
|
if (raw === void 0) return {};
|
|
@@ -1281,6 +1542,36 @@ function blockMessage(decision) {
|
|
|
1281
1542
|
}
|
|
1282
1543
|
}
|
|
1283
1544
|
//#endregion
|
|
1545
|
+
//#region src/builtin-rules.ts
|
|
1546
|
+
/**
|
|
1547
|
+
* The shipped high-risk permission baseline: the absolute path of the
|
|
1548
|
+
* bundled `rules/builtin-high-risk.yaml` data file and the resolution of a
|
|
1549
|
+
* possibly-custom builtin path. The shipped path is computed relative to
|
|
1550
|
+
* THIS module so it resolves identically from the source plane (tests,
|
|
1551
|
+
* where this file lives under `src/`) and the bundled plane (where it is
|
|
1552
|
+
* inlined into `lib/index.js`, one directory below the package root).
|
|
1553
|
+
* @module dsh-permission-rules/builtin-rules
|
|
1554
|
+
*/
|
|
1555
|
+
/** File name of the bundled baseline, under the package's `rules/` directory. */
|
|
1556
|
+
const BUILTIN_RULES_FILE = "builtin-high-risk.yaml";
|
|
1557
|
+
/**
|
|
1558
|
+
* Absolute path of the shipped baseline. Not existence-checked here — a
|
|
1559
|
+
* missing file is a mount-time failure in {@link PermissionRulesRuntime},
|
|
1560
|
+
* never a silent skip.
|
|
1561
|
+
*/
|
|
1562
|
+
const SHIPPED_BUILTIN_RULES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "rules", BUILTIN_RULES_FILE);
|
|
1563
|
+
/**
|
|
1564
|
+
* Resolve the builtin ruleset path: an explicit `builtin.path` (absolute
|
|
1565
|
+
* as-is, relative against `process.cwd()` — mirroring `fallbackPath`), else
|
|
1566
|
+
* the shipped file.
|
|
1567
|
+
* @param custom - the optional `builtin.path` config value.
|
|
1568
|
+
* @returns the absolute builtin ruleset path.
|
|
1569
|
+
*/
|
|
1570
|
+
function resolveBuiltinRulesPath(custom) {
|
|
1571
|
+
if (custom === void 0) return SHIPPED_BUILTIN_RULES;
|
|
1572
|
+
return isAbsolute(custom) ? custom : resolve(custom);
|
|
1573
|
+
}
|
|
1574
|
+
//#endregion
|
|
1284
1575
|
//#region src/config.ts
|
|
1285
1576
|
/**
|
|
1286
1577
|
* Config schema and resolution for `dsh-permission-rules`. Every tunable is
|
|
@@ -1323,6 +1614,10 @@ const Config = z.object({
|
|
|
1323
1614
|
loopback: z.union(["allow", "policy"]).default("allow"),
|
|
1324
1615
|
injectEnv: z.boolean().default(true),
|
|
1325
1616
|
noProxy: z.union(["clear", "preserve"]).default("clear")
|
|
1617
|
+
}),
|
|
1618
|
+
builtin: z.object({
|
|
1619
|
+
enabled: z.boolean().default(true),
|
|
1620
|
+
path: z.string()
|
|
1326
1621
|
})
|
|
1327
1622
|
});
|
|
1328
1623
|
/**
|
|
@@ -1363,6 +1658,7 @@ function resolveConfig(config = {}) {
|
|
|
1363
1658
|
assertBoolean("enforce", config.enforce ?? true);
|
|
1364
1659
|
assertBoolean("allowUnmarkedAudit", config.allowUnmarkedAudit ?? false);
|
|
1365
1660
|
const network = resolveNetworkConfig(config.network);
|
|
1661
|
+
const builtin = resolveBuiltinConfig(config.builtin);
|
|
1366
1662
|
return {
|
|
1367
1663
|
rulesFile,
|
|
1368
1664
|
fallbackPath: config.fallbackPath,
|
|
@@ -1379,7 +1675,17 @@ function resolveConfig(config = {}) {
|
|
|
1379
1675
|
maxGlobStars,
|
|
1380
1676
|
enforce: config.enforce ?? true,
|
|
1381
1677
|
allowUnmarkedAudit: config.allowUnmarkedAudit ?? false,
|
|
1382
|
-
network
|
|
1678
|
+
network,
|
|
1679
|
+
builtin
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
/** Validate and default the optional `builtin` block; bad values fail the mount loudly. */
|
|
1683
|
+
function resolveBuiltinConfig(raw) {
|
|
1684
|
+
assertBoolean("builtin.enabled", raw?.enabled ?? true);
|
|
1685
|
+
const path = resolveBuiltinRulesPath(raw?.path);
|
|
1686
|
+
return {
|
|
1687
|
+
enabled: raw?.enabled ?? true,
|
|
1688
|
+
path
|
|
1383
1689
|
};
|
|
1384
1690
|
}
|
|
1385
1691
|
/** Validate and default the optional `network` block; bad values fail the mount loudly. */
|
|
@@ -1618,6 +1924,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1618
1924
|
absent: "absent",
|
|
1619
1925
|
when: "when",
|
|
1620
1926
|
platform: "platform",
|
|
1927
|
+
argv: "argv",
|
|
1621
1928
|
network: "network",
|
|
1622
1929
|
domains: "domains",
|
|
1623
1930
|
ips: "ips",
|
|
@@ -1636,6 +1943,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1636
1943
|
absent: "缺省键",
|
|
1637
1944
|
when: "条件",
|
|
1638
1945
|
platform: "平台",
|
|
1946
|
+
argv: "命令",
|
|
1639
1947
|
network: "网络",
|
|
1640
1948
|
domains: "域名",
|
|
1641
1949
|
ips: "IP",
|
|
@@ -1654,6 +1962,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1654
1962
|
absent: "ausentes",
|
|
1655
1963
|
when: "cuándo",
|
|
1656
1964
|
platform: "plataforma",
|
|
1965
|
+
argv: "comando",
|
|
1657
1966
|
network: "red",
|
|
1658
1967
|
domains: "dominios",
|
|
1659
1968
|
ips: "IP",
|
|
@@ -1672,6 +1981,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1672
1981
|
absent: "ausentes",
|
|
1673
1982
|
when: "quando",
|
|
1674
1983
|
platform: "plataforma",
|
|
1984
|
+
argv: "comando",
|
|
1675
1985
|
network: "rede",
|
|
1676
1986
|
domains: "domínios",
|
|
1677
1987
|
ips: "IP",
|
|
@@ -1690,6 +2000,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1690
2000
|
absent: "अनुपस्थित",
|
|
1691
2001
|
when: "शर्त",
|
|
1692
2002
|
platform: "प्लेटफ़ॉर्म",
|
|
2003
|
+
argv: "कमांड",
|
|
1693
2004
|
network: "नेटवर्क",
|
|
1694
2005
|
domains: "डोमेन",
|
|
1695
2006
|
ips: "IP",
|
|
@@ -2133,6 +2444,8 @@ var PermissionRulesRuntime = class {
|
|
|
2133
2444
|
ctx;
|
|
2134
2445
|
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
2135
2446
|
byCwd = /* @__PURE__ */ new Map();
|
|
2447
|
+
/** Compiled per-source rules keyed by absolute path, with the content hash they were compiled from (LRU-refreshed). */
|
|
2448
|
+
compileCache = /* @__PURE__ */ new Map();
|
|
2136
2449
|
/** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
|
|
2137
2450
|
watchers = /* @__PURE__ */ new Map();
|
|
2138
2451
|
/** Debounce timers per rule-file path. */
|
|
@@ -2170,15 +2483,28 @@ var PermissionRulesRuntime = class {
|
|
|
2170
2483
|
};
|
|
2171
2484
|
}
|
|
2172
2485
|
/**
|
|
2173
|
-
* Resolve which files serve a workspace, nearest first: the
|
|
2174
|
-
* under the session cwd
|
|
2175
|
-
* also merging every parent directory's file on the
|
|
2176
|
-
* else the configured fallback, else `[]`
|
|
2177
|
-
*
|
|
2486
|
+
* Resolve which files serve a workspace, nearest first: the USER rule
|
|
2487
|
+
* files (project file under the session cwd — or an absolute `rulesFile`
|
|
2488
|
+
* — with `searchUp` also merging every parent directory's file on the
|
|
2489
|
+
* way to the root, else the configured fallback, else `[]`), followed by
|
|
2490
|
+
* the built-in high-risk baseline when enabled. The baseline sits LAST so
|
|
2491
|
+
* first-match semantics let any nearer user rule override it; with no
|
|
2492
|
+
* user files it applies alone. Nearer files evaluate first, so a child
|
|
2493
|
+
* can override a parent rule.
|
|
2178
2494
|
* @param cwd - the session's absolute workspace root.
|
|
2179
2495
|
* @returns the absolute rule-file paths in effect, nearest first.
|
|
2180
2496
|
*/
|
|
2181
2497
|
resolveSources(cwd) {
|
|
2498
|
+
const user = this.resolveUserSources(cwd);
|
|
2499
|
+
const builtin = this.builtinSource();
|
|
2500
|
+
return builtin === void 0 ? user : [...user, builtin];
|
|
2501
|
+
}
|
|
2502
|
+
/** The built-in baseline path when enabled, else `undefined`. */
|
|
2503
|
+
builtinSource() {
|
|
2504
|
+
return this.config.builtin.enabled ? this.config.builtin.path : void 0;
|
|
2505
|
+
}
|
|
2506
|
+
/** The user rule files serving a workspace (project chain → fallback → empty), nearest first. */
|
|
2507
|
+
resolveUserSources(cwd) {
|
|
2182
2508
|
if (this.config.searchUp) {
|
|
2183
2509
|
const sources = [];
|
|
2184
2510
|
let dir = cwd;
|
|
@@ -2223,14 +2549,10 @@ var PermissionRulesRuntime = class {
|
|
|
2223
2549
|
}
|
|
2224
2550
|
};
|
|
2225
2551
|
try {
|
|
2226
|
-
const { ruleset } = compileRulesChain(sources.map((path) => ({
|
|
2227
|
-
path,
|
|
2228
|
-
text: readFileSync(path, "utf8")
|
|
2229
|
-
})), this.compileOptions());
|
|
2230
2552
|
return {
|
|
2231
2553
|
cwd,
|
|
2232
2554
|
sources,
|
|
2233
|
-
compiled:
|
|
2555
|
+
compiled: mergeCompiledRules(sources.map((path) => this.compileSourceFile(path)), this.compileOptions())
|
|
2234
2556
|
};
|
|
2235
2557
|
} catch (error) {
|
|
2236
2558
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2250,6 +2572,41 @@ var PermissionRulesRuntime = class {
|
|
|
2250
2572
|
}
|
|
2251
2573
|
}
|
|
2252
2574
|
/**
|
|
2575
|
+
* Compile one source file, reusing the cached compiled rules when the
|
|
2576
|
+
* file's content hash is unchanged. The cache key is the source path plus
|
|
2577
|
+
* the content hash, so the same file read by many workspaces (the
|
|
2578
|
+
* built-in baseline and any shared fallback/absolute `rulesFile`) is
|
|
2579
|
+
* parsed and compiled ONCE, not once per cwd.
|
|
2580
|
+
* @param path - the absolute source path.
|
|
2581
|
+
* @returns that source's compiled rules (source-local index, sourceIndex 0).
|
|
2582
|
+
*/
|
|
2583
|
+
compileSourceFile(path) {
|
|
2584
|
+
const text = readFileSync(path, "utf8");
|
|
2585
|
+
const hash = createHash("sha256").update(text).digest("hex");
|
|
2586
|
+
const cached = this.compileCache.get(path);
|
|
2587
|
+
if (cached !== void 0 && cached.hash === hash) {
|
|
2588
|
+
this.compileCache.delete(path);
|
|
2589
|
+
this.compileCache.set(path, cached);
|
|
2590
|
+
return cached.rules;
|
|
2591
|
+
}
|
|
2592
|
+
const rules = compileSourceRules(parseRulesDocument(text), this.compileOptions());
|
|
2593
|
+
this.compileCache.set(path, {
|
|
2594
|
+
hash,
|
|
2595
|
+
rules
|
|
2596
|
+
});
|
|
2597
|
+
this.evictCompileCache();
|
|
2598
|
+
return rules;
|
|
2599
|
+
}
|
|
2600
|
+
/** Bound the compile cache: drop the least-recently-used source beyond the workspace cap (compile results are cheap to rebuild). */
|
|
2601
|
+
evictCompileCache() {
|
|
2602
|
+
const cap = this.config.maxCachedWorkspaces;
|
|
2603
|
+
while (this.compileCache.size > cap) {
|
|
2604
|
+
const oldest = this.compileCache.keys().next().value;
|
|
2605
|
+
if (oldest === void 0) return;
|
|
2606
|
+
this.compileCache.delete(oldest);
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2253
2610
|
* Canonical per-workspace cache key: the resolved root, case-folded on
|
|
2254
2611
|
* Windows so differently-spelled paths to the same workspace share one
|
|
2255
2612
|
* cache entry and one watcher set instead of doubling both.
|
|
@@ -2695,13 +3052,19 @@ var PermissionRulesRuntime = class {
|
|
|
2695
3052
|
* The rule-file paths the settings-page editor may read or write: every
|
|
2696
3053
|
* currently loaded source plus the per-workspace project files and the
|
|
2697
3054
|
* configured fallback (so a not-yet-existing project file can be
|
|
2698
|
-
* created). The editor never touches arbitrary paths
|
|
3055
|
+
* created). The editor never touches arbitrary paths — and the built-in
|
|
3056
|
+
* baseline is excluded entirely (it is a shipped, read-only baseline,
|
|
3057
|
+
* shown via `/rules` attribution instead).
|
|
2699
3058
|
*/
|
|
2700
3059
|
knownRuleSources() {
|
|
2701
3060
|
const cfg = this.config;
|
|
3061
|
+
const builtin = cfg.builtin.enabled ? cfg.builtin.path : void 0;
|
|
2702
3062
|
const set = /* @__PURE__ */ new Set();
|
|
2703
3063
|
for (const loaded of this.byCwd.values()) {
|
|
2704
|
-
for (const source of loaded.sources)
|
|
3064
|
+
for (const source of loaded.sources) {
|
|
3065
|
+
if (source === builtin) continue;
|
|
3066
|
+
set.add(source);
|
|
3067
|
+
}
|
|
2705
3068
|
if (!isAbsolute(cfg.rulesFile)) set.add(join(loaded.cwd, cfg.rulesFile));
|
|
2706
3069
|
}
|
|
2707
3070
|
if (cfg.fallbackPath !== void 0) set.add(isAbsolute(cfg.fallbackPath) ? cfg.fallbackPath : resolve(cfg.fallbackPath));
|
|
@@ -2752,6 +3115,10 @@ var PermissionRulesRuntime = class {
|
|
|
2752
3115
|
* is in effect immediately.
|
|
2753
3116
|
*/
|
|
2754
3117
|
saveRuleFile(path, text) {
|
|
3118
|
+
if (this.config.builtin.enabled && path === this.config.builtin.path) return {
|
|
3119
|
+
ok: false,
|
|
3120
|
+
error: `refusing to write ${path}: the built-in ruleset is read-only`
|
|
3121
|
+
};
|
|
2755
3122
|
if (!this.knownRuleSources().includes(path)) return {
|
|
2756
3123
|
ok: false,
|
|
2757
3124
|
error: `refusing to write ${path}: not a known rule source`
|
|
@@ -3038,6 +3405,10 @@ var PermissionRulesRuntime = class {
|
|
|
3038
3405
|
if (!existsSync(fallbackPath)) throw new RuleError(`permission-rules: fallbackPath ${JSON.stringify(this.config.fallbackPath)} does not exist`);
|
|
3039
3406
|
this.loadForValidation(fallbackPath);
|
|
3040
3407
|
}
|
|
3408
|
+
if (this.config.builtin.enabled) {
|
|
3409
|
+
if (!existsSync(this.config.builtin.path)) throw new RuleError(`permission-rules: builtin ruleset ${JSON.stringify(this.config.builtin.path)} does not exist`);
|
|
3410
|
+
this.loadForValidation(this.config.builtin.path);
|
|
3411
|
+
}
|
|
3041
3412
|
}
|
|
3042
3413
|
/** Parse and compile one mandated file, rethrowing as a mount failure. */
|
|
3043
3414
|
loadForValidation(filePath) {
|
|
@@ -3050,7 +3421,7 @@ var PermissionRulesRuntime = class {
|
|
|
3050
3421
|
}
|
|
3051
3422
|
/** Attach (once per file path) the Chokidar watcher feeding {@link reload}. */
|
|
3052
3423
|
attachWatch(cwd, source) {
|
|
3053
|
-
if (!this.config.watch || source === "") return;
|
|
3424
|
+
if (!this.config.watch || source === "" || this.config.builtin.enabled && source === this.config.builtin.path) return;
|
|
3054
3425
|
const existing = this.watchers.get(source);
|
|
3055
3426
|
if (existing !== void 0) {
|
|
3056
3427
|
existing.cwds.add(cwd);
|
|
@@ -3217,6 +3588,10 @@ var PermissionRulesRuntime = class {
|
|
|
3217
3588
|
activeWatcherCount() {
|
|
3218
3589
|
return this.watchers.size;
|
|
3219
3590
|
}
|
|
3591
|
+
/** Number of cached compiled source files, for observability and tests. */
|
|
3592
|
+
compiledSourceCount() {
|
|
3593
|
+
return this.compileCache.size;
|
|
3594
|
+
}
|
|
3220
3595
|
/** Number of pending debounce timers, for observability and tests. */
|
|
3221
3596
|
pendingReloadCount() {
|
|
3222
3597
|
return this.timers.size;
|
|
@@ -3374,4 +3749,4 @@ function nextToken(text) {
|
|
|
3374
3749
|
const name = "permission-rules";
|
|
3375
3750
|
const inject = ["commands", "tools"];
|
|
3376
3751
|
//#endregion
|
|
3377
|
-
export { Config, GlobError, NETWORK_MODES, NO_PROXY_ENV_NAMES, NetworkProxy, PATH_CANDIDATE_KEYS, PLATFORMS, PROXY_ENV_NAMES, PermissionRulesRuntime, RULE_ACTIONS, RuleError, SCHEMES, URL_CANDIDATE_KEYS, apply, blockMessage, compileGlob, compilePatternRegex, compileRules, compileRulesChain, decideNetworkTarget, defaultDecision, describeRule, extractPathCandidates, extractUrlCandidates, findUnreachableRules, inject, injectProxyEnv, isIpLiteral, isLoopbackTarget, isMarkedAuditEvent, isShellTool, isUnmarkedHostVersion, matchRules, matchTools, matchWhen, name, networkModeForSandbox, normalizeWorkspacePath, parseRulesDocument, parseUrlTarget, resolveConfig, targetMatchesNetwork };
|
|
3752
|
+
export { BUILTIN_RULES_FILE, Config, GlobError, NETWORK_MODES, NO_PROXY_ENV_NAMES, NetworkProxy, PATH_CANDIDATE_KEYS, PLATFORMS, PROXY_ENV_NAMES, PermissionRulesRuntime, RULE_ACTIONS, RuleError, SCHEMES, SHIPPED_BUILTIN_RULES, URL_CANDIDATE_KEYS, apply, blockMessage, compileGlob, compilePatternRegex, compileRules, compileRulesChain, compileSourceRules, decideNetworkTarget, decomposeShellCommand, defaultDecision, describeRule, extractPathCandidates, extractUrlCandidates, findUnreachableRules, inject, injectProxyEnv, isIpLiteral, isLoopbackTarget, isMarkedAuditEvent, isShellTool, isUnmarkedHostVersion, matchRules, matchTools, matchWhen, mergeCompiledRules, name, networkModeForSandbox, normalizeWorkspacePath, parseRulesDocument, parseUrlTarget, resolveBuiltinRulesPath, resolveConfig, targetMatchesNetwork };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shipped high-risk permission baseline: the absolute path of the
|
|
3
|
+
* bundled `rules/builtin-high-risk.yaml` data file and the resolution of a
|
|
4
|
+
* possibly-custom builtin path. The shipped path is computed relative to
|
|
5
|
+
* THIS module so it resolves identically from the source plane (tests,
|
|
6
|
+
* where this file lives under `src/`) and the bundled plane (where it is
|
|
7
|
+
* inlined into `lib/index.js`, one directory below the package root).
|
|
8
|
+
* @module dsh-permission-rules/builtin-rules
|
|
9
|
+
*/
|
|
10
|
+
/** File name of the bundled baseline, under the package's `rules/` directory. */
|
|
11
|
+
export declare const BUILTIN_RULES_FILE = "builtin-high-risk.yaml";
|
|
12
|
+
/**
|
|
13
|
+
* Absolute path of the shipped baseline. Not existence-checked here — a
|
|
14
|
+
* missing file is a mount-time failure in {@link PermissionRulesRuntime},
|
|
15
|
+
* never a silent skip.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SHIPPED_BUILTIN_RULES: string;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the builtin ruleset path: an explicit `builtin.path` (absolute
|
|
20
|
+
* as-is, relative against `process.cwd()` — mirroring `fallbackPath`), else
|
|
21
|
+
* the shipped file.
|
|
22
|
+
* @param custom - the optional `builtin.path` config value.
|
|
23
|
+
* @returns the absolute builtin ruleset path.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveBuiltinRulesPath(custom: string | undefined): string;
|
|
26
|
+
//# sourceMappingURL=builtin-rules.d.ts.map
|