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/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: doc.rules.map((entry, index) => compileRule(entry, index, 0, options)),
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 project file
2174
- * under the session cwd (or an absolute `rulesFile`), with `searchUp`
2175
- * also merging every parent directory's file on the way to the root,
2176
- * else the configured fallback, else `[]` (empty rule set). Nearer files
2177
- * evaluate first, so a child can override a parent rule.
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: ruleset
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) set.add(source);
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