dsh-permission-rules 0.4.2 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.es.md +160 -117
  3. package/README.hi.md +162 -119
  4. package/README.md +133 -90
  5. package/README.pt.md +161 -118
  6. package/README.zh.md +162 -119
  7. package/docs/rule-test-vectors/README.md +28 -0
  8. package/docs/rule-test-vectors/corpus.json +109 -0
  9. package/lib/client.js +4693 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/index.js +1530 -179
  12. package/lib/typert.host.js +4305 -0
  13. package/lib/types/client/index.d.ts +28 -0
  14. package/lib/types/client/index.d.ts.map +1 -0
  15. package/lib/types/client/locales.d.ts +9 -0
  16. package/lib/types/client/locales.d.ts.map +1 -0
  17. package/lib/types/client/remote.d.ts +181 -0
  18. package/lib/types/client/remote.d.ts.map +1 -0
  19. package/lib/types/client/section.d.ts +35 -0
  20. package/lib/types/client/section.d.ts.map +1 -0
  21. package/lib/types/config.d.ts +54 -2
  22. package/lib/types/config.d.ts.map +1 -1
  23. package/lib/types/events.d.ts +45 -0
  24. package/lib/types/events.d.ts.map +1 -1
  25. package/lib/types/index.d.ts +8 -6
  26. package/lib/types/index.d.ts.map +1 -1
  27. package/lib/types/network.d.ts +90 -0
  28. package/lib/types/network.d.ts.map +1 -0
  29. package/lib/types/prose.d.ts +15 -0
  30. package/lib/types/prose.d.ts.map +1 -1
  31. package/lib/types/proxy.d.ts +128 -0
  32. package/lib/types/proxy.d.ts.map +1 -0
  33. package/lib/types/remote-service.d.ts +45 -0
  34. package/lib/types/remote-service.d.ts.map +1 -0
  35. package/lib/types/rules.d.ts +131 -0
  36. package/lib/types/rules.d.ts.map +1 -1
  37. package/lib/types/runtime.d.ts +153 -21
  38. package/lib/types/runtime.d.ts.map +1 -1
  39. package/lib/types/settings.d.ts +28 -0
  40. package/lib/types/settings.d.ts.map +1 -0
  41. package/lib/types/typert.host.d.ts +166 -0
  42. package/lib/types/typert.host.d.ts.map +1 -0
  43. package/lib/types/wire.d.ts +421 -0
  44. package/lib/types/wire.d.ts.map +1 -0
  45. package/package.json +182 -144
  46. package/scripts/repair-session-logs.mjs +7 -2
  47. package/src/client/index.ts +111 -0
  48. package/src/client/locales.ts +94 -0
  49. package/src/client/remote.ts +41 -0
  50. package/src/client/section.ts +201 -0
  51. package/src/config.ts +111 -0
  52. package/src/events.ts +46 -0
  53. package/src/index.ts +2 -0
  54. package/src/network.ts +177 -0
  55. package/src/prose.ts +85 -10
  56. package/src/proxy.ts +327 -0
  57. package/src/remote-service.ts +109 -0
  58. package/src/rules.ts +424 -5
  59. package/src/runtime.ts +464 -53
  60. package/src/settings.ts +74 -0
  61. package/src/typert.host.ts +25 -0
  62. package/src/wire.ts +239 -0
  63. package/lib/types/config.js +0 -99
  64. package/lib/types/config.js.map +0 -1
  65. package/lib/types/events.js +0 -44
  66. package/lib/types/events.js.map +0 -1
  67. package/lib/types/glob.js +0 -307
  68. package/lib/types/glob.js.map +0 -1
  69. package/lib/types/index.js +0 -27
  70. package/lib/types/index.js.map +0 -1
  71. package/lib/types/prose.js +0 -173
  72. package/lib/types/prose.js.map +0 -1
  73. package/lib/types/rules.js +0 -610
  74. package/lib/types/rules.js.map +0 -1
  75. package/lib/types/runtime.js +0 -818
  76. package/lib/types/runtime.js.map +0 -1
package/lib/index.js CHANGED
@@ -1,105 +1,14 @@
1
1
  import { createRequire } from "node:module";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import chokidar from "chokidar";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { parse } from "yaml";
7
- //#region src/config.ts
8
- /**
9
- * Config schema and resolution for `dsh-permission-rules`. Every tunable is
10
- * a validated {@link Config} field changeable from cordis.yml; the
11
- * resolution step validates the numeric bounds and compiles nothing — rule
12
- * files are external documents resolved per session cwd at load time.
13
- * @module dsh-permission-rules/config
14
- */
15
- /** Schemastery schema: the loader validates and fills defaults before `apply`. */
16
- const Config = z.object({
17
- rulesFile: z.string().default(".dsh/rules.yaml"),
18
- fallbackPath: z.string(),
19
- badFilePolicy: z.union(["fail", "ignore-with-warning"]).default("fail"),
20
- maxRules: z.number().default(256),
21
- maxCachedWorkspaces: z.number().default(512),
22
- patternMode: z.union(["glob", "regex"]).default("glob"),
23
- watch: z.boolean().default(true),
24
- watchStabilityThresholdMs: z.number().default(200),
25
- language: z.union([
26
- "en",
27
- "zh",
28
- "es",
29
- "pt",
30
- "hi"
31
- ]).default("en"),
32
- caseInsensitivePaths: z.boolean().default(process.platform === "win32"),
33
- audit: z.union(["all", "hits"]).default("all"),
34
- searchUp: z.boolean().default(false),
35
- maxGlobStars: z.number().default(2),
36
- enforce: z.boolean().default(true),
37
- allowUnmarkedAudit: z.boolean().default(false)
38
- });
39
- /**
40
- * Validate raw values and fill explicit defaults. A `maxRules`,
41
- * `maxCachedWorkspaces`, or `maxGlobStars` that is not a positive safe
42
- * integer, a non-positive stability window, `searchUp` combined with an
43
- * absolute `rulesFile`, a value outside a closed enum, or a non-boolean
44
- * flag throws here — misconfiguration fails loud at mount even when the
45
- * plugin is mounted without the Schemastery loader.
46
- * @param config - raw (possibly partial) plugin config.
47
- * @returns the fully resolved config.
48
- */
49
- function resolveConfig(config = {}) {
50
- const rulesFile = config.rulesFile ?? ".dsh/rules.yaml";
51
- const searchUp = config.searchUp ?? false;
52
- if (searchUp && isAbsolute(rulesFile)) throw new TypeError(`searchUp cannot be combined with an absolute rulesFile (${JSON.stringify(rulesFile)}): parent-directory walking needs a relative file name`);
53
- const maxRules = config.maxRules ?? 256;
54
- if (!Number.isSafeInteger(maxRules) || maxRules <= 0) throw new TypeError(`maxRules must be a positive safe integer, got ${String(config.maxRules)}`);
55
- const watchStabilityThresholdMs = config.watchStabilityThresholdMs ?? 200;
56
- if (!Number.isSafeInteger(watchStabilityThresholdMs) || watchStabilityThresholdMs < 0) throw new TypeError(`watchStabilityThresholdMs must be a non-negative safe integer, got ${String(config.watchStabilityThresholdMs)}`);
57
- const maxCachedWorkspaces = config.maxCachedWorkspaces ?? 512;
58
- if (!Number.isSafeInteger(maxCachedWorkspaces) || maxCachedWorkspaces <= 0) throw new TypeError(`maxCachedWorkspaces must be a positive safe integer, got ${String(config.maxCachedWorkspaces)}`);
59
- const maxGlobStars = config.maxGlobStars ?? 2;
60
- if (!Number.isSafeInteger(maxGlobStars) || maxGlobStars <= 0) throw new TypeError(`maxGlobStars must be a positive safe integer, got ${String(config.maxGlobStars)}`);
61
- assertEnum("badFilePolicy", config.badFilePolicy ?? "fail", ["fail", "ignore-with-warning"]);
62
- assertEnum("patternMode", config.patternMode ?? "glob", ["glob", "regex"]);
63
- assertEnum("language", config.language ?? "en", [
64
- "en",
65
- "zh",
66
- "es",
67
- "pt",
68
- "hi"
69
- ]);
70
- assertEnum("audit", config.audit ?? "all", ["all", "hits"]);
71
- assertBoolean("watch", config.watch ?? true);
72
- assertBoolean("searchUp", searchUp);
73
- assertBoolean("caseInsensitivePaths", config.caseInsensitivePaths ?? process.platform === "win32");
74
- assertBoolean("enforce", config.enforce ?? true);
75
- assertBoolean("allowUnmarkedAudit", config.allowUnmarkedAudit ?? false);
76
- return {
77
- rulesFile,
78
- fallbackPath: config.fallbackPath,
79
- badFilePolicy: config.badFilePolicy ?? "fail",
80
- maxRules,
81
- maxCachedWorkspaces,
82
- patternMode: config.patternMode ?? "glob",
83
- watch: config.watch ?? true,
84
- watchStabilityThresholdMs,
85
- language: config.language ?? "en",
86
- caseInsensitivePaths: config.caseInsensitivePaths ?? process.platform === "win32",
87
- audit: config.audit ?? "all",
88
- searchUp,
89
- maxGlobStars,
90
- enforce: config.enforce ?? true,
91
- allowUnmarkedAudit: config.allowUnmarkedAudit ?? false
92
- };
93
- }
94
- /** Throw unless `key` is one of `allowed` (TypeScript's closed enums are not runtime checks). */
95
- function assertEnum(key, value, allowed) {
96
- if (!allowed.includes(value)) throw new TypeError(`${key} must be one of ${allowed.map((item) => JSON.stringify(item)).join(" | ")}, got ${JSON.stringify(value)}`);
97
- }
98
- /** Throw unless `key` is a boolean (plain-JS mounts bypass the Schemastery coercion). */
99
- function assertBoolean(key, value) {
100
- if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean, got ${typeof value}`);
101
- }
102
- //#endregion
7
+ import { createServer, request } from "node:http";
8
+ import { request as request$1 } from "node:https";
9
+ import { connect } from "node:net";
10
+ import { lookup } from "node:dns/promises";
11
+ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
103
12
  //#region src/glob.ts
104
13
  /**
105
14
  * Strict glob→RegExp compilation and regex-safety validation for rule
@@ -397,6 +306,32 @@ const RULE_ACTIONS = [
397
306
  "deny",
398
307
  "ask"
399
308
  ];
309
+ /** Closed scheme list for runtime normalization of untrusted values. */
310
+ const SCHEMES = ["http", "https"];
311
+ /** Hostname characters that make a URL candidate parseable as a network target. */
312
+ const URL_CANDIDATE_KEYS = [
313
+ "url",
314
+ "urls",
315
+ "uri",
316
+ "endpoint",
317
+ "base_url",
318
+ "baseUrl",
319
+ "webhook",
320
+ "link",
321
+ "href",
322
+ "origin",
323
+ "remote",
324
+ "repository",
325
+ "repo"
326
+ ];
327
+ /** Top-level argument keys whose string values hold a shell command (URL-scanned for shell tools). */
328
+ const COMMAND_CANDIDATE_KEYS = [
329
+ "command",
330
+ "cmd",
331
+ "script",
332
+ "command_line",
333
+ "commandLine"
334
+ ];
400
335
  /** Platform names a `when.platform` entry may name (Node `process.platform` values). */
401
336
  const PLATFORMS = [
402
337
  "aix",
@@ -508,6 +443,7 @@ function compileRule(entry, index, sourceIndex, options) {
508
443
  env,
509
444
  platform: entry.match.when.platform
510
445
  } },
446
+ ...entry.match.network === void 0 ? {} : { network: compileNetwork(entry.match.network, options.maxGlobStars) },
511
447
  source: entry
512
448
  };
513
449
  }
@@ -533,6 +469,7 @@ function matchRules(ruleset, toolName, args, cwd, context = {}) {
533
469
  if (rule.params.size > 0 && (argRecord === void 0 || !matchParams(rule, argRecord))) continue;
534
470
  if (rule.absent.length > 0 && !matchAbsent(rule, argRecord)) continue;
535
471
  if (rule.paths.length > 0 && (argRecord === void 0 || !matchPaths(rule, argRecord, cwd, ruleset.caseInsensitivePaths))) continue;
472
+ if (rule.network !== void 0 && (argRecord === void 0 || !matchNetwork(rule, argRecord))) continue;
536
473
  return {
537
474
  ruleIndex: rule.index,
538
475
  rule
@@ -562,7 +499,7 @@ function findUnreachableRules(ruleset) {
562
499
  }
563
500
  /** Whether every match dimension of one rule is empty. */
564
501
  function isCatchAll(rule) {
565
- 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;
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;
566
503
  }
567
504
  /**
568
505
  * One-line human summary of a compiled rule, for `/rules` output. The
@@ -587,6 +524,14 @@ function describeRule(rule, tokens, source) {
587
524
  const whenParts = Object.entries(rule.source.match.when.env).map(([key, patterns]) => `${key}=${patterns.join("|")}`).join(" ");
588
525
  const platformPart = rule.source.match.when.platform.length > 0 ? `${tokens.platform}:${rule.source.match.when.platform.join(",")}` : "";
589
526
  if (whenParts.length > 0 || platformPart.length > 0) parts.push(`${tokens.when}:${whenParts}${whenParts.length > 0 && platformPart.length > 0 ? " " : ""}${platformPart}`);
527
+ const networkParts = [];
528
+ if (rule.source.match.network !== void 0) {
529
+ if (rule.source.match.network.domains.length > 0) networkParts.push(`${tokens.domains}:${rule.source.match.network.domains.join(",")}`);
530
+ if (rule.source.match.network.ips.length > 0) networkParts.push(`${tokens.ips}:${rule.source.match.network.ips.join(",")}`);
531
+ if (rule.source.match.network.ports.length > 0) networkParts.push(`${tokens.ports}:${rule.source.match.network.ports.join(",")}`);
532
+ if (rule.source.match.network.schemes.length > 0) networkParts.push(`${tokens.schemes}:${rule.source.match.network.schemes.join(",")}`);
533
+ }
534
+ if (networkParts.length > 0) parts.push(`${tokens.network}:${networkParts.join(" ")}`);
590
535
  const match = parts.length > 0 ? `[${parts.join(" ")}]` : `[${tokens.allTools}]`;
591
536
  const disabled = rule.enabled ? "" : ` (${tokens.disabled})`;
592
537
  const sourcePart = source !== void 0 && source.length > 0 ? ` [${tokens.src}:${source}]` : "";
@@ -736,15 +681,16 @@ function parseMatch(raw, at) {
736
681
  }
737
682
  };
738
683
  const record = asRecord(raw, `${at}.match`);
739
- const unknownFields = Object.keys(record).filter((key) => key !== "tools" && key !== "agents" && key !== "params" && key !== "paths" && key !== "absent" && key !== "when");
740
- 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)`);
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)`);
741
686
  return {
742
687
  tools: parseStringList(record["tools"], `${at}.match.tools`),
743
688
  agents: parseStringList(record["agents"], `${at}.match.agents`),
744
689
  params: parseParams(record["params"], `${at}.match.params`),
745
690
  paths: parseStringList(record["paths"], `${at}.match.paths`),
746
691
  absent: parseStringList(record["absent"], `${at}.match.absent`),
747
- when: parseWhen(record["when"], `${at}.match.when`)
692
+ when: parseWhen(record["when"], `${at}.match.when`),
693
+ ...record["network"] === void 0 ? {} : { network: parseNetwork(record["network"], `${at}.match.network`) }
748
694
  };
749
695
  }
750
696
  /** Validate the params map: non-empty string keys → one pattern or a pattern list. */
@@ -788,6 +734,37 @@ function parseWhen(raw, at) {
788
734
  };
789
735
  }
790
736
  /**
737
+ * Validate the `network` block of one rule. Every listed pattern is
738
+ * shape-validated here (port ranges, CIDR strings, closed scheme
739
+ * vocabulary); compilation of globs happens in {@link compileNetwork}.
740
+ * Scalar values (YAML numbers like `443` for ports) are stringified like
741
+ * every other pattern list, so numeric ports parse as their decimal
742
+ * strings. A block with every dimension empty is rejected — a network
743
+ * rule must name at least one target dimension.
744
+ */
745
+ function parseNetwork(raw, at) {
746
+ const record = asRecord(raw, at);
747
+ const unknownFields = Object.keys(record).filter((key) => key !== "domains" && key !== "ips" && key !== "ports" && key !== "schemes");
748
+ if (unknownFields.length > 0) throw new RuleError(`${at}: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: domains, ips, ports, schemes)`);
749
+ const domains = parsePatternValues(record["domains"], `${at}.domains`);
750
+ const ips = parsePatternValues(record["ips"], `${at}.ips`);
751
+ for (const pattern of ips) parseIpPattern(pattern, `${at}.ips`);
752
+ const ports = parsePatternValues(record["ports"], `${at}.ports`);
753
+ for (const pattern of ports) parsePortPattern(pattern, `${at}.ports`);
754
+ const schemes = [];
755
+ for (const [index, value] of parsePatternValues(record["schemes"], `${at}.schemes`).entries()) {
756
+ if (!SCHEMES.includes(value)) throw new RuleError(`${at}.schemes[${index}] must be one of ${SCHEMES.map((s) => JSON.stringify(s)).join(" | ")}, got ${JSON.stringify(value)}`);
757
+ schemes.push(value);
758
+ }
759
+ if (domains.length === 0 && ips.length === 0 && ports.length === 0 && schemes.length === 0) throw new RuleError(`${at}: a network block must name at least one of domains, ips, ports, schemes (drop the block for a tool-level rule)`);
760
+ return {
761
+ domains,
762
+ ips,
763
+ ports,
764
+ schemes
765
+ };
766
+ }
767
+ /**
791
768
  * Validate one pattern or a pattern list; scalar strings/numbers/booleans
792
769
  * are single patterns (matching the runtime scalar-stringify semantics).
793
770
  */
@@ -871,6 +848,263 @@ function extractPathCandidates(args) {
871
848
  walk(args, 0);
872
849
  return candidates;
873
850
  }
851
+ /** One regex the command-scan extracts URL substrings with (embedded URLs in shell command text). */
852
+ const EMBEDDED_URL = /https?:\/\/[^\s"'<>)\]\\]+/g;
853
+ /**
854
+ * Whether the tool is a shell tool: its `command` argument is scanned for
855
+ * embedded URLs. Every other tool contributes URL candidates only through
856
+ * {@link URL_CANDIDATE_KEYS}.
857
+ */
858
+ function isShellTool(toolName) {
859
+ return toolName === "bash" || toolName === "pwsh";
860
+ }
861
+ /**
862
+ * Collect URL-candidate strings from tool arguments: values under the
863
+ * {@link URL_CANDIDATE_KEYS} at ANY nesting depth (capped like path
864
+ * extraction), plus embedded `http(s)://` substrings of command-shaped
865
+ * arguments (shell command text). Deterministic: own key order, duplicates
866
+ * kept.
867
+ * @param args - the parsed tool arguments.
868
+ * @returns raw candidate strings, each already an http(s) URL or a bare host.
869
+ */
870
+ function extractUrlCandidates(args) {
871
+ const candidates = [];
872
+ const walk = (node, depth) => {
873
+ if (depth > MAX_ARGUMENT_DEPTH) return;
874
+ if (Array.isArray(node)) {
875
+ for (const element of node) walk(element, depth + 1);
876
+ return;
877
+ }
878
+ if (typeof node !== "object" || node === null) return;
879
+ for (const [key, value] of Object.entries(node)) {
880
+ if (URL_CANDIDATE_KEYS.includes(key)) {
881
+ if (typeof value === "string") candidates.push(value);
882
+ else if (Array.isArray(value)) {
883
+ for (const element of value) if (typeof element === "string") candidates.push(element);
884
+ }
885
+ }
886
+ if (COMMAND_CANDIDATE_KEYS.includes(key) && typeof value === "string") for (const match of value.matchAll(EMBEDDED_URL)) candidates.push(match[0]);
887
+ walk(value, depth + 1);
888
+ }
889
+ };
890
+ walk(args, 0);
891
+ return candidates;
892
+ }
893
+ /**
894
+ * Parse one raw candidate into a {@link NetworkTarget}. Full `http(s)://`
895
+ * URLs parse through WHATWG URL semantics; a bare `host[:port]` candidate
896
+ * (no spaces, no slashes) becomes a scheme-less target (the `scheme` key
897
+ * is present with value `undefined`). Anything else yields `undefined` —
898
+ * a rule simply cannot match a non-target candidate.
899
+ * @param raw - the candidate string.
900
+ * @returns the parsed target, or undefined.
901
+ */
902
+ function parseUrlTarget(raw) {
903
+ const text = raw.trim();
904
+ if (text.length === 0 || /\s/.test(text)) return void 0;
905
+ const parsed = tryParseHttpUrl(text);
906
+ if (parsed !== void 0) return parsed;
907
+ if (!/^[A-Za-z0-9._[\]-]+(?::\d+)?$/.test(text)) return void 0;
908
+ const colon = text.lastIndexOf(":");
909
+ const host = normalizeHost(colon < 0 ? text : text.slice(0, colon));
910
+ if (host.length === 0) return void 0;
911
+ const port = colon < 0 ? void 0 : parsePortNumber(text.slice(colon + 1));
912
+ if (colon >= 0 && port === void 0) return void 0;
913
+ return {
914
+ scheme: void 0,
915
+ host,
916
+ port,
917
+ ips: literalIpOf(host)
918
+ };
919
+ }
920
+ /** Parse a full http(s) URL, or undefined when it is not one. */
921
+ function tryParseHttpUrl(text) {
922
+ let url;
923
+ try {
924
+ url = new URL(text);
925
+ } catch {
926
+ const bare = /^(https?):\/\/([0-9a-fA-F:]+)(?::(\d+))?(\/.*)?$/.exec(text);
927
+ if (bare === null || bare[2] === void 0 || bare[2].split(":").length < 3) return void 0;
928
+ const rewritten = `${bare[1]}://[${bare[2]}]${bare[3] !== void 0 ? `:${bare[3]}` : ""}${bare[4] ?? ""}`;
929
+ try {
930
+ url = new URL(rewritten);
931
+ } catch {
932
+ return;
933
+ }
934
+ }
935
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
936
+ const host = normalizeHost(url.hostname);
937
+ if (host.length === 0) return void 0;
938
+ const port = url.port.length > 0 ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
939
+ return {
940
+ scheme: url.protocol === "https:" ? "https" : "http",
941
+ host,
942
+ port,
943
+ ips: literalIpOf(host)
944
+ };
945
+ }
946
+ /** Lowercase, strip IPv6 brackets, strip one trailing dot. */
947
+ function normalizeHost(host) {
948
+ let out = host.toLowerCase().replace(/^\[|\]$/g, "");
949
+ while (out.endsWith(".")) out = out.slice(0, -1);
950
+ return out;
951
+ }
952
+ /** The literal IP of a host, or `[]` when the host is a name. */
953
+ function literalIpOf(host) {
954
+ return isIpLiteral(host) ? [host.toLowerCase()] : [];
955
+ }
956
+ /** Whether a host string is an IP literal (IPv4 or IPv6). */
957
+ function isIpLiteral(host) {
958
+ return /^[0-9.]+$/.test(host) ? /^\d{1,3}(\.\d{1,3}){3}$/.test(host) : host.includes(":");
959
+ }
960
+ /** Parse `80` or `8000-9000` or `*`; invalid shapes throw at load. */
961
+ function parsePortPattern(pattern, at) {
962
+ if (pattern === "*") return;
963
+ const single = /^\d+$/.exec(pattern);
964
+ if (single !== null) {
965
+ if (Number(single[0]) > 65535) throw new RuleError(`${at}: port ${JSON.stringify(pattern)} is out of range (0-65535)`);
966
+ return;
967
+ }
968
+ const range = /^(\d+)-(\d+)$/.exec(pattern);
969
+ if (range === null) throw new RuleError(`${at}: port ${JSON.stringify(pattern)} must be "*", a single port, or a low-high range like "8000-9000"`);
970
+ const low = Number(range[1]);
971
+ const high = Number(range[2]);
972
+ if (low > high || low > 65535 || high > 65535) throw new RuleError(`${at}: port range ${JSON.stringify(pattern)} is invalid (0-65535, low <= high)`);
973
+ }
974
+ /** Validate one IP pattern shape (exact literal, glob, or IPv4 CIDR). */
975
+ function parseIpPattern(pattern, at) {
976
+ if (pattern.length === 0) throw new RuleError(`${at}: IP patterns must be non-empty`);
977
+ const cidr = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(pattern);
978
+ if (cidr !== null) {
979
+ if (cidr.slice(1, 5).map(Number).some((octet) => octet > 255) || Number(cidr[5]) > 32) throw new RuleError(`${at}: CIDR ${JSON.stringify(pattern)} is invalid (octets 0-255, prefix 0-32)`);
980
+ }
981
+ }
982
+ /** Parse one port pattern into its compiled range; shape-validated at parse time. */
983
+ function compilePortPattern(pattern) {
984
+ if (pattern === "*") return {
985
+ min: 0,
986
+ max: Infinity
987
+ };
988
+ const single = /^\d+$/.exec(pattern);
989
+ if (single !== null) {
990
+ const port = Number(single[0]);
991
+ return {
992
+ min: port,
993
+ max: port
994
+ };
995
+ }
996
+ const range = /^(\d+)-(\d+)$/.exec(pattern);
997
+ return {
998
+ min: Number(range?.[1] ?? 0),
999
+ max: Number(range?.[2] ?? 0)
1000
+ };
1001
+ }
1002
+ /** Compile one IP pattern: CIDR first, then glob; exact literals glob-compile to themselves. */
1003
+ function compileIpPattern(pattern, maxGlobStars) {
1004
+ const cidr = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(pattern);
1005
+ if (cidr !== null) {
1006
+ const octets = cidr.slice(1, 5).map(Number);
1007
+ const prefix = Number(cidr[5]);
1008
+ return { cidr: {
1009
+ network: (octets[0] << 24 | octets[1] << 16 | octets[2] << 8 | octets[3]) >>> 0,
1010
+ prefix
1011
+ } };
1012
+ }
1013
+ if (!/[[*?]/.test(pattern) && !pattern.includes(":")) return { literal: pattern.toLowerCase() };
1014
+ return { regex: compileGlob(pattern, {
1015
+ segments: false,
1016
+ maxStars: maxGlobStars,
1017
+ caseInsensitive: true
1018
+ }) };
1019
+ }
1020
+ /**
1021
+ * Compile one domain pattern. A pattern without glob metacharacters is
1022
+ * subdomain-inclusive (Codex semantics: `example.com` also matches
1023
+ * `api.example.com`); anything with `*`/`?`/`[` compiles as a
1024
+ * case-insensitive glob (so `*.example.com` matches subdomains only).
1025
+ */
1026
+ function compileDomainPattern(pattern, maxGlobStars) {
1027
+ const normalized = normalizeHost(pattern);
1028
+ if (normalized.length === 0) throw new RuleError(`network domain patterns must be non-empty, got ${JSON.stringify(pattern)}`);
1029
+ if (/[*?[\]\\]/.test(normalized)) return compileGlob(normalized, {
1030
+ segments: false,
1031
+ maxStars: maxGlobStars,
1032
+ caseInsensitive: true
1033
+ });
1034
+ const literal = normalized.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
1035
+ return new RegExp(`^(?:[A-Za-z0-9_-]+\\.)*${literal}$`, "iu");
1036
+ }
1037
+ /** Compile the network block of one rule; pattern errors throw at load. */
1038
+ function compileNetwork(doc, maxGlobStars) {
1039
+ return {
1040
+ domains: doc.domains.map((pattern) => compileDomainPattern(pattern, maxGlobStars)),
1041
+ ips: doc.ips.map((pattern) => compileIpPattern(pattern, maxGlobStars)),
1042
+ ports: doc.ports.map(compilePortPattern),
1043
+ schemes: doc.schemes
1044
+ };
1045
+ }
1046
+ /**
1047
+ * Whether a rule's network dimension holds for a tool call: at least one
1048
+ * URL candidate must parse into a target satisfying EVERY listed
1049
+ * dimension. Calls without any URL candidate can never satisfy a
1050
+ * network-scoped rule (like the paths dimension).
1051
+ */
1052
+ function matchNetwork(rule, args) {
1053
+ const network = rule.network;
1054
+ if (network === void 0) return true;
1055
+ return extractUrlCandidates(args).map(parseUrlTarget).filter((target) => target !== void 0).some((target) => targetMatchesNetwork(target, network));
1056
+ }
1057
+ /**
1058
+ * Evaluate one target against one compiled network block (pure; shared by
1059
+ * the pre-execute path and the proxy layer). Domain patterns are tested
1060
+ * against the host string as-is — including literal-IP hosts, so a
1061
+ * catch-all glob like `*` matches `127.0.0.1` too (Codex parity keeps
1062
+ * loopback reachable only via the `loopback` short-circuit, never by
1063
+ * name matching). Literal-IP hosts are tested against `ips` patterns
1064
+ * directly; `target.ips` may carry additional resolved addresses
1065
+ * supplied by the proxy.
1066
+ * @param target - the parsed target.
1067
+ * @param network - the compiled network dimensions.
1068
+ * @returns true when EVERY listed dimension holds.
1069
+ */
1070
+ function targetMatchesNetwork(target, network) {
1071
+ if (network.domains.length > 0) {
1072
+ if (!network.domains.some((regex) => regex.test(target.host))) return false;
1073
+ }
1074
+ if (network.ips.length > 0) {
1075
+ const candidates = [...target.ips];
1076
+ if (!network.ips.some((pattern) => candidates.some((candidate) => ipMatches(pattern, candidate)))) return false;
1077
+ }
1078
+ if (network.ports.length > 0) {
1079
+ const port = target.port;
1080
+ if (port === void 0 || !network.ports.some((range) => port >= range.min && port <= range.max)) return false;
1081
+ }
1082
+ if (network.schemes.length > 0) {
1083
+ if (target.scheme === void 0 || !network.schemes.includes(target.scheme)) return false;
1084
+ }
1085
+ return true;
1086
+ }
1087
+ /** Whether one compiled IP pattern matches one candidate address (lowercased, unbracketed). */
1088
+ function ipMatches(pattern, candidate) {
1089
+ const normalized = normalizeHost(candidate);
1090
+ if (pattern.cidr !== void 0) {
1091
+ const parts = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized);
1092
+ if (parts === null) return false;
1093
+ const octets = parts.slice(1, 5).map(Number);
1094
+ if (octets.some((octet) => octet > 255)) return false;
1095
+ const value = (octets[0] << 24 | octets[1] << 16 | octets[2] << 8 | octets[3]) >>> 0;
1096
+ const mask = pattern.cidr.prefix === 0 ? 0 : 4294967295 << 32 - pattern.cidr.prefix >>> 0;
1097
+ return (value & mask) === (pattern.cidr.network & mask);
1098
+ }
1099
+ if (pattern.literal !== void 0) return normalized === pattern.literal;
1100
+ return pattern.regex?.test(normalized) ?? false;
1101
+ }
1102
+ /** Parse one explicit port number, or undefined when out of range. */
1103
+ function parsePortNumber(text) {
1104
+ if (!/^\d+$/.test(text)) return void 0;
1105
+ const port = Number(text);
1106
+ return port <= 65535 ? port : void 0;
1107
+ }
874
1108
  /**
875
1109
  * Normalize one candidate path against the workspace root: separators
876
1110
  * become `/`, `./` prefixes drop, and in-root paths become workspace
@@ -907,18 +1141,287 @@ function relFromRoot(root, path, rest, rootRest, caseInsensitive) {
907
1141
  if (path === root || caseInsensitive && path.toLowerCase() === root.toLowerCase()) return "";
908
1142
  return restLower.startsWith(`${rootRestLower}/`) ? rest.slice(rootRest.length + 1) : "";
909
1143
  }
910
- /** Convert a path to posix separators. */
911
- function toPosix(path) {
912
- return path.replaceAll("\\", "/");
1144
+ /** Convert a path to posix separators. */
1145
+ function toPosix(path) {
1146
+ return path.replaceAll("\\", "/");
1147
+ }
1148
+ /** Collect scalar candidate strings from one param value, recursing into arrays and objects. */
1149
+ function scalarCandidates(value, depth) {
1150
+ if (depth > MAX_ARGUMENT_DEPTH) return [];
1151
+ if (typeof value === "string") return [value];
1152
+ if (typeof value === "number" || typeof value === "boolean") return [String(value)];
1153
+ if (Array.isArray(value)) return value.flatMap((element) => scalarCandidates(element, depth + 1));
1154
+ if (typeof value === "object" && value !== null) return Object.values(value).flatMap((child) => scalarCandidates(child, depth + 1));
1155
+ return [];
1156
+ }
1157
+ //#endregion
1158
+ //#region src/network.ts
1159
+ /** Closed mode list for runtime normalization of untrusted values. */
1160
+ const NETWORK_MODES = [
1161
+ "deny-all",
1162
+ "whitelist",
1163
+ "allow-all"
1164
+ ];
1165
+ /**
1166
+ * Map one official sandbox preset onto its network mode, Codex-style:
1167
+ * `read-only` defaults to no network (whitelist only), `workspace-write`
1168
+ * keeps a whitelist with ask/deny for unlisted targets, and
1169
+ * `danger-full-access` opens everything (rules can still narrow it). Any
1170
+ * other value falls back to `fallback` — hosts without the sandbox-policy
1171
+ * service therefore stay permissive, preserving pre-network behavior.
1172
+ * @param sandbox - the resolved sandbox mode, or undefined.
1173
+ * @param fallback - the mode used when the preset is unknown/absent.
1174
+ * @returns the effective network mode.
1175
+ */
1176
+ function networkModeForSandbox(sandbox, fallback) {
1177
+ switch (sandbox) {
1178
+ case "read-only": return "deny-all";
1179
+ case "workspace-write": return "whitelist";
1180
+ case "danger-full-access": return "allow-all";
1181
+ default: return fallback;
1182
+ }
1183
+ }
1184
+ /** The default decision a mode yields for a target no rule matched. */
1185
+ function defaultDecision(mode, unlisted) {
1186
+ switch (mode) {
1187
+ case "deny-all": return {
1188
+ action: "deny",
1189
+ matched: false,
1190
+ mode
1191
+ };
1192
+ case "whitelist": return {
1193
+ action: unlisted,
1194
+ matched: false,
1195
+ mode
1196
+ };
1197
+ case "allow-all": return {
1198
+ action: "allow",
1199
+ matched: false,
1200
+ mode
1201
+ };
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Evaluate the network policy for one target across the loaded rule
1206
+ * chains (nearest workspace first, first match wins). Proxy-layer tool
1207
+ * attribution: a shell subprocess connection counts as `bash`/`pwsh`
1208
+ * candidates, so a rule scoped to either shell tool can fire and one
1209
+ * scoped to web tools never does. Agent identity is unknown at the proxy,
1210
+ * so agent-scoped network rules fail closed there (the pre-execute path
1211
+ * is where agents are known). Loopback targets short-circuit before any
1212
+ * rule when `loopback: 'allow'` (Codex parity: local dev servers stay
1213
+ * reachable in the read-only default).
1214
+ * @param chains - the loaded chains, insertion (nearest) order first.
1215
+ * @param target - the parsed connection/URL target.
1216
+ * @param options - mode, unlisted handling, loopback policy, host facts.
1217
+ * @returns the decision.
1218
+ */
1219
+ function decideNetworkTarget(chains, target, options) {
1220
+ if (options.loopback === "allow" && isLoopbackTarget(target)) return {
1221
+ action: "allow",
1222
+ matched: false,
1223
+ mode: options.mode
1224
+ };
1225
+ const context = options.context ?? {};
1226
+ for (const chain of chains) for (const rule of chain.ruleset.rules) {
1227
+ if (!rule.enabled) continue;
1228
+ if (rule.network === void 0) continue;
1229
+ if (!matchToolsProxy(rule)) continue;
1230
+ if (rule.when !== void 0 && !matchWhen(rule, context)) continue;
1231
+ if (!targetMatchesNetwork(target, rule.network)) continue;
1232
+ return {
1233
+ action: rule.action,
1234
+ matched: true,
1235
+ mode: options.mode,
1236
+ ruleIndex: rule.index,
1237
+ rule,
1238
+ source: chain.sources[rule.sourceIndex] ?? ""
1239
+ };
1240
+ }
1241
+ return defaultDecision(options.mode, options.unlisted);
1242
+ }
1243
+ /**
1244
+ * Tool-scope check for proxy traffic: shell subprocess connections carry
1245
+ * the `bash`/`pwsh` identity candidates (ANY match satisfies, like the
1246
+ * agents dimension); rules scoped to other tools never fire here.
1247
+ */
1248
+ function matchToolsProxy(rule) {
1249
+ if (rule.tools.length === 0) return true;
1250
+ return matchTools(rule, "bash") || matchTools(rule, "pwsh");
1251
+ }
1252
+ /** Whether a target addresses the loopback range. */
1253
+ function isLoopbackTarget(target) {
1254
+ const host = target.host;
1255
+ if (host === "localhost" || host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
1256
+ const parts = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
1257
+ if (parts === null) return false;
1258
+ return parts.slice(1).every((octet) => Number(octet) <= 255) && parts[1] === "127";
1259
+ }
1260
+ /**
1261
+ * The short structured message a blocked proxy connection receives (403
1262
+ * body for plain HTTP, CONNECT error body for tunnels). Clients such as
1263
+ * curl print it verbatim, so the model sees the reason through the shell
1264
+ * tool output. An `ask` at the proxy layer is worded as a pending-approval
1265
+ * block because the proxy has no session context for the interactive
1266
+ * approval seam.
1267
+ * @param decision - the decision that blocked the connection.
1268
+ * @returns a short text/plain message.
1269
+ */
1270
+ function blockMessage(decision) {
1271
+ const reason = decision.rule?.reason ?? "";
1272
+ if (decision.matched) {
1273
+ const rulePart = decision.ruleIndex !== void 0 ? ` by rule ${decision.ruleIndex + 1}` : "";
1274
+ if (decision.action === "ask") return `[network: blocked pending approval${rulePart}] ${reason}\n(subprocess connections cannot ride the interactive approval seam — ask your user or add an allow rule for this target)`;
1275
+ return `[network: denied${rulePart}] ${reason}`;
1276
+ }
1277
+ switch (decision.mode) {
1278
+ case "deny-all": return "[network: denied] network mode deny-all (read-only sandbox preset): only whitelisted targets are reachable";
1279
+ case "whitelist": return decision.action === "ask" ? "[network: blocked pending approval] whitelist mode: the target is not matched by an allow rule" : "[network: denied] whitelist mode: the target is not matched by an allow rule";
1280
+ case "allow-all": return "[network: denied]";
1281
+ }
1282
+ }
1283
+ //#endregion
1284
+ //#region src/config.ts
1285
+ /**
1286
+ * Config schema and resolution for `dsh-permission-rules`. Every tunable is
1287
+ * a validated {@link Config} field changeable from cordis.yml; the
1288
+ * resolution step validates the numeric bounds and compiles nothing — rule
1289
+ * files are external documents resolved per session cwd at load time.
1290
+ * @module dsh-permission-rules/config
1291
+ */
1292
+ /** Schemastery schema: the loader validates and fills defaults before `apply`. */
1293
+ const Config = z.object({
1294
+ rulesFile: z.string().default(".dsh/rules.yaml"),
1295
+ fallbackPath: z.string(),
1296
+ badFilePolicy: z.union(["fail", "ignore-with-warning"]).default("fail"),
1297
+ maxRules: z.number().default(256),
1298
+ maxCachedWorkspaces: z.number().default(512),
1299
+ patternMode: z.union(["glob", "regex"]).default("glob"),
1300
+ watch: z.boolean().default(true),
1301
+ watchStabilityThresholdMs: z.number().default(200),
1302
+ language: z.union([
1303
+ "en",
1304
+ "zh",
1305
+ "es",
1306
+ "pt",
1307
+ "hi"
1308
+ ]).default("en"),
1309
+ caseInsensitivePaths: z.boolean().default(process.platform === "win32"),
1310
+ audit: z.union(["all", "hits"]).default("all"),
1311
+ searchUp: z.boolean().default(false),
1312
+ maxGlobStars: z.number().default(2),
1313
+ enforce: z.boolean().default(true),
1314
+ allowUnmarkedAudit: z.boolean().default(false),
1315
+ network: z.object({
1316
+ enabled: z.boolean().default(true),
1317
+ mode: z.union(["auto", ...NETWORK_MODES]).default("auto"),
1318
+ autoFallback: z.union(NETWORK_MODES).default("allow-all"),
1319
+ unlisted: z.union(["ask", "deny"]).default("ask"),
1320
+ proxyBind: z.string().default("127.0.0.1"),
1321
+ proxyPort: z.number().default(0),
1322
+ proxyMaxRecent: z.number().default(100),
1323
+ loopback: z.union(["allow", "policy"]).default("allow"),
1324
+ injectEnv: z.boolean().default(true),
1325
+ noProxy: z.union(["clear", "preserve"]).default("clear")
1326
+ })
1327
+ });
1328
+ /**
1329
+ * Validate raw values and fill explicit defaults. A `maxRules`,
1330
+ * `maxCachedWorkspaces`, or `maxGlobStars` that is not a positive safe
1331
+ * integer, a non-positive stability window, `searchUp` combined with an
1332
+ * absolute `rulesFile`, a value outside a closed enum, or a non-boolean
1333
+ * flag throws here — misconfiguration fails loud at mount even when the
1334
+ * plugin is mounted without the Schemastery loader.
1335
+ * @param config - raw (possibly partial) plugin config.
1336
+ * @returns the fully resolved config.
1337
+ */
1338
+ function resolveConfig(config = {}) {
1339
+ const rulesFile = config.rulesFile ?? ".dsh/rules.yaml";
1340
+ const searchUp = config.searchUp ?? false;
1341
+ if (searchUp && isAbsolute(rulesFile)) throw new TypeError(`searchUp cannot be combined with an absolute rulesFile (${JSON.stringify(rulesFile)}): parent-directory walking needs a relative file name`);
1342
+ const maxRules = config.maxRules ?? 256;
1343
+ if (!Number.isSafeInteger(maxRules) || maxRules <= 0) throw new TypeError(`maxRules must be a positive safe integer, got ${String(config.maxRules)}`);
1344
+ const watchStabilityThresholdMs = config.watchStabilityThresholdMs ?? 200;
1345
+ if (!Number.isSafeInteger(watchStabilityThresholdMs) || watchStabilityThresholdMs < 0) throw new TypeError(`watchStabilityThresholdMs must be a non-negative safe integer, got ${String(config.watchStabilityThresholdMs)}`);
1346
+ const maxCachedWorkspaces = config.maxCachedWorkspaces ?? 512;
1347
+ if (!Number.isSafeInteger(maxCachedWorkspaces) || maxCachedWorkspaces <= 0) throw new TypeError(`maxCachedWorkspaces must be a positive safe integer, got ${String(config.maxCachedWorkspaces)}`);
1348
+ const maxGlobStars = config.maxGlobStars ?? 2;
1349
+ if (!Number.isSafeInteger(maxGlobStars) || maxGlobStars <= 0) throw new TypeError(`maxGlobStars must be a positive safe integer, got ${String(config.maxGlobStars)}`);
1350
+ assertEnum("badFilePolicy", config.badFilePolicy ?? "fail", ["fail", "ignore-with-warning"]);
1351
+ assertEnum("patternMode", config.patternMode ?? "glob", ["glob", "regex"]);
1352
+ assertEnum("language", config.language ?? "en", [
1353
+ "en",
1354
+ "zh",
1355
+ "es",
1356
+ "pt",
1357
+ "hi"
1358
+ ]);
1359
+ assertEnum("audit", config.audit ?? "all", ["all", "hits"]);
1360
+ assertBoolean("watch", config.watch ?? true);
1361
+ assertBoolean("searchUp", searchUp);
1362
+ assertBoolean("caseInsensitivePaths", config.caseInsensitivePaths ?? process.platform === "win32");
1363
+ assertBoolean("enforce", config.enforce ?? true);
1364
+ assertBoolean("allowUnmarkedAudit", config.allowUnmarkedAudit ?? false);
1365
+ const network = resolveNetworkConfig(config.network);
1366
+ return {
1367
+ rulesFile,
1368
+ fallbackPath: config.fallbackPath,
1369
+ badFilePolicy: config.badFilePolicy ?? "fail",
1370
+ maxRules,
1371
+ maxCachedWorkspaces,
1372
+ patternMode: config.patternMode ?? "glob",
1373
+ watch: config.watch ?? true,
1374
+ watchStabilityThresholdMs,
1375
+ language: config.language ?? "en",
1376
+ caseInsensitivePaths: config.caseInsensitivePaths ?? process.platform === "win32",
1377
+ audit: config.audit ?? "all",
1378
+ searchUp,
1379
+ maxGlobStars,
1380
+ enforce: config.enforce ?? true,
1381
+ allowUnmarkedAudit: config.allowUnmarkedAudit ?? false,
1382
+ network
1383
+ };
1384
+ }
1385
+ /** Validate and default the optional `network` block; bad values fail the mount loudly. */
1386
+ function resolveNetworkConfig(raw) {
1387
+ const mode = raw?.mode ?? "auto";
1388
+ if (mode !== "auto") assertEnum("network.mode", mode, NETWORK_MODES);
1389
+ const autoFallback = raw?.autoFallback ?? "allow-all";
1390
+ assertEnum("network.autoFallback", autoFallback, NETWORK_MODES);
1391
+ const unlisted = raw?.unlisted ?? "ask";
1392
+ assertEnum("network.unlisted", unlisted, ["ask", "deny"]);
1393
+ const loopback = raw?.loopback ?? "allow";
1394
+ assertEnum("network.loopback", loopback, ["allow", "policy"]);
1395
+ const noProxy = raw?.noProxy ?? "clear";
1396
+ assertEnum("network.noProxy", noProxy, ["clear", "preserve"]);
1397
+ assertBoolean("network.enabled", raw?.enabled ?? true);
1398
+ assertBoolean("network.injectEnv", raw?.injectEnv ?? true);
1399
+ const proxyBind = raw?.proxyBind ?? "127.0.0.1";
1400
+ if (typeof proxyBind !== "string" || proxyBind.trim().length === 0) throw new TypeError(`network.proxyBind must be a non-empty string, got ${typeof proxyBind}`);
1401
+ const proxyPort = raw?.proxyPort ?? 0;
1402
+ if (!Number.isSafeInteger(proxyPort) || proxyPort < 0 || proxyPort > 65535) throw new TypeError(`network.proxyPort must be an integer 0-65535, got ${String(raw?.proxyPort)}`);
1403
+ const proxyMaxRecent = raw?.proxyMaxRecent ?? 100;
1404
+ if (!Number.isSafeInteger(proxyMaxRecent) || proxyMaxRecent <= 0) throw new TypeError(`network.proxyMaxRecent must be a positive safe integer, got ${String(raw?.proxyMaxRecent)}`);
1405
+ return {
1406
+ enabled: raw?.enabled ?? true,
1407
+ mode,
1408
+ autoFallback,
1409
+ unlisted,
1410
+ proxyBind: proxyBind.trim(),
1411
+ proxyPort,
1412
+ proxyMaxRecent,
1413
+ loopback,
1414
+ injectEnv: raw?.injectEnv ?? true,
1415
+ noProxy
1416
+ };
1417
+ }
1418
+ /** Throw unless `key` is one of `allowed` (TypeScript's closed enums are not runtime checks). */
1419
+ function assertEnum(key, value, allowed) {
1420
+ if (!allowed.includes(value)) throw new TypeError(`${key} must be one of ${allowed.map((item) => JSON.stringify(item)).join(" | ")}, got ${JSON.stringify(value)}`);
913
1421
  }
914
- /** Collect scalar candidate strings from one param value, recursing into arrays and objects. */
915
- function scalarCandidates(value, depth) {
916
- if (depth > MAX_ARGUMENT_DEPTH) return [];
917
- if (typeof value === "string") return [value];
918
- if (typeof value === "number" || typeof value === "boolean") return [String(value)];
919
- if (Array.isArray(value)) return value.flatMap((element) => scalarCandidates(element, depth + 1));
920
- if (typeof value === "object" && value !== null) return Object.values(value).flatMap((child) => scalarCandidates(child, depth + 1));
921
- return [];
1422
+ /** Throw unless `key` is a boolean (plain-JS mounts bypass the Schemastery coercion). */
1423
+ function assertBoolean(key, value) {
1424
+ if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean, got ${typeof value}`);
922
1425
  }
923
1426
  //#endregion
924
1427
  //#region src/prose.ts
@@ -935,7 +1438,7 @@ const EN = {
935
1438
  lastReloadWarning: (error) => `Warning: the last reload failed (${error}); the rules listed above are the previous ones.`,
936
1439
  dryRunNotice: "Dry-run mode (enforce: false): deny/ask hits are audit-logged only — every call is passed through.",
937
1440
  unknownArg: (arg) => `Unknown /rules argument "${arg}". ${EN.usage}`,
938
- usage: "Usage: /rules [list | reload | decisions [n] | test <tool> <json-args>]",
1441
+ usage: "Usage: /rules [list | reload | network | decisions [n] | test <tool> <json-args>]",
939
1442
  decisionsHeader: (shown, total) => `Last ${shown} of ${total} permission decision(s) for this session:`,
940
1443
  noDecisions: "No permission decisions recorded in this session yet.",
941
1444
  auditDisabledNotice: "Session-log audit is disabled on this host: its Session.append predates the ignorable marker and would make sessions unresumable elsewhere (set allowUnmarkedAudit: true to opt back in).",
@@ -951,7 +1454,14 @@ const EN = {
951
1454
  testBadPlatform: (value) => `Unknown platform "${value}" in --platform: expected one of aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${EN.testUsage}`,
952
1455
  testUsage: "Usage: /rules test [--cwd <dir>] [--env KEY=VALUE]... [--agent <selector>]... [--platform <name>] <tool> <json-args>, e.g. /rules test bash {\"command\":\"git push origin main\"}",
953
1456
  unreachableWarning: (numbers) => `Warning: rule${numbers.length > 1 ? "s" : ""} ${numbers.join(", ")} ${numbers.length > 1 ? "are" : "is"} unreachable (shadowed by an earlier catch-all rule).`,
954
- emptySource: "(no rule file — empty rule set)"
1457
+ emptySource: "(no rule file — empty rule set)",
1458
+ networkDisabled: "Network policy disabled (network.enabled: false): no proxy, no web-tool mode defaults.",
1459
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Network policy: mode ${mode}${sandboxMode === void 0 ? "" : ` (sandbox preset ${sandboxMode})`}${configuredMode === "auto" ? "" : ` (configured ${configuredMode})`}; proxy ${proxyActive ? `active on 127.0.0.1:${proxyPort}` : "INACTIVE (bind failed — shell network policy is not enforced)"}.`,
1460
+ networkCounters: (denied, askBlocked) => `Blocks: ${denied} denied, ${askBlocked} ask-blocked.`,
1461
+ noNetworkBlocks: "No network blocks recorded yet.",
1462
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
1463
+ return `- ${new Date(time).toISOString()} ${action} ${`${tool}${attributed ? "" : " (unattributed)"}`} → ${`${scheme ?? "?"}://${domain}${port === void 0 ? "" : `:${port}`}`}${matched ? ruleIndex === void 0 ? "" : ` (rule ${ruleIndex + 1})` : " (mode default)"}${reason === void 0 ? "" : `: ${short(reason)}`}`;
1464
+ }
955
1465
  };
956
1466
  const ZH = {
957
1467
  rulesHeader: (count, sources, cwd) => `权限规则:共 ${count} 条,来自 ${sources.join("、")}(工作区 ${cwd})。`,
@@ -962,7 +1472,7 @@ const ZH = {
962
1472
  lastReloadWarning: (error) => `警告:上次重载失败(${error});以上列出的是之前的规则。`,
963
1473
  dryRunNotice: "干跑模式(enforce: false):deny/ask 命中仅写入审计日志——所有调用照常透传。",
964
1474
  unknownArg: (arg) => `未知的 /rules 参数 "${arg}"。${ZH.usage}`,
965
- usage: "用法:/rules [list | reload | decisions [n] | test <工具名> <json-参数>]",
1475
+ usage: "用法:/rules [list | reload | network | decisions [n] | test <工具名> <json-参数>]",
966
1476
  decisionsHeader: (shown, total) => `本会话最近 ${shown}/${total} 条权限裁决:`,
967
1477
  noDecisions: "本会话还没有记录任何权限裁决。",
968
1478
  auditDisabledNotice: "本宿主上会话日志审计已停用:其 Session.append 早于 ignorable 标记支持,会令会话在其他宿主上无法恢复(设 allowUnmarkedAudit: true 可重新开启)。",
@@ -978,7 +1488,14 @@ const ZH = {
978
1488
  testBadPlatform: (value) => `未知的平台 "${value}"(--platform):应为 aix、android、darwin、freebsd、linux、openbsd、sunos、win32 之一。${ZH.testUsage}`,
979
1489
  testUsage: "用法:/rules test [--cwd <目录>] [--env 键=值]... [--agent <选择器>]... [--platform <平台名>] <工具名> <json-参数>,例如 /rules test bash {\"command\":\"git push origin main\"}",
980
1490
  unreachableWarning: (numbers) => `警告:规则 ${numbers.join("、")} 不可达(被前面的通配规则遮蔽)。`,
981
- emptySource: "(无规则文件——空规则集)"
1491
+ emptySource: "(无规则文件——空规则集)",
1492
+ networkDisabled: "网络策略已停用(network.enabled: false):无代理、无 web 工具模式默认裁决。",
1493
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `网络策略:模式 ${mode}${sandboxMode === void 0 ? "" : `(沙箱预设 ${sandboxMode})`}${configuredMode === "auto" ? "" : `(显式配置 ${configuredMode})`};代理${proxyActive ? `运行于 127.0.0.1:${proxyPort}` : "未激活(绑定失败——shell 网络策略未生效)"}。`,
1494
+ networkCounters: (denied, askBlocked) => `拦截:拒绝 ${denied} 次,待审批阻断 ${askBlocked} 次。`,
1495
+ noNetworkBlocks: "尚未记录任何网络拦截。",
1496
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
1497
+ return `- ${new Date(time).toISOString()} ${action} ${`${tool}${attributed ? "" : "(未归属)"}`} → ${`${scheme ?? "?"}://${domain}${port === void 0 ? "" : `:${port}`}`}${matched ? ruleIndex === void 0 ? "" : `(规则 ${ruleIndex + 1})` : "(模式默认)"}${reason === void 0 ? "" : `:${short(reason)}`}`;
1498
+ }
982
1499
  };
983
1500
  const ES = {
984
1501
  rulesHeader: (count, sources, cwd) => `Reglas de permiso: ${count} regla(s) desde ${sources.join(", ")} (espacio de trabajo ${cwd}).`,
@@ -989,7 +1506,7 @@ const ES = {
989
1506
  lastReloadWarning: (error) => `Aviso: la última recarga falló (${error}); las reglas mostradas son las anteriores.`,
990
1507
  dryRunNotice: "Modo simulación (enforce: false): los aciertos deny/ask solo se registran en auditoría — todas las llamadas pasan.",
991
1508
  unknownArg: (arg) => `Argumento de /rules desconocido "${arg}". ${ES.usage}`,
992
- usage: "Uso: /rules [list | reload | decisions [n] | test <herramienta> <json-args>]",
1509
+ usage: "Uso: /rules [list | reload | network | decisions [n] | test <herramienta> <json-args>]",
993
1510
  decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisión(es) de permiso de esta sesión:`,
994
1511
  noDecisions: "Esta sesión aún no registra decisiones de permiso.",
995
1512
  auditDisabledNotice: "La auditoría del registro de sesión está desactivada en este host: su Session.append es anterior al marcador ignorable y haría las sesiones irrecuperables en otros hosts (establece allowUnmarkedAudit: true para reactivarla).",
@@ -1005,7 +1522,14 @@ const ES = {
1005
1522
  testBadPlatform: (value) => `Plataforma desconocida "${value}" en --platform: se esperaba una de aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${ES.testUsage}`,
1006
1523
  testUsage: "Uso: /rules test [--cwd <dir>] [--env CLAVE=VALOR]... [--agent <selector>]... [--platform <nombre>] <herramienta> <json-args>, p. ej. /rules test bash {\"command\":\"git push origin main\"}",
1007
1524
  unreachableWarning: (numbers) => `Aviso: ${numbers.length > 1 ? "las reglas" : "la regla"} ${numbers.join(", ")} ${numbers.length > 1 ? "son inalcanzables" : "es inalcanzable"} (tapada por una regla general anterior).`,
1008
- emptySource: "(sin archivo de reglas — conjunto vacío)"
1525
+ emptySource: "(sin archivo de reglas — conjunto vacío)",
1526
+ networkDisabled: "Política de red desactivada (network.enabled: false): sin proxy, sin valores predeterminados de modo para herramientas web.",
1527
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Política de red: modo ${mode}${sandboxMode === void 0 ? "" : ` (preset sandbox ${sandboxMode})`}${configuredMode === "auto" ? "" : ` (configurado ${configuredMode})`}; proxy ${proxyActive ? `activo en 127.0.0.1:${proxyPort}` : "INACTIVO (fallo de bind — la política de red de shell no se aplica)"}.`,
1528
+ networkCounters: (denied, askBlocked) => `Bloqueos: ${denied} denegados, ${askBlocked} bloqueados por aprobación.`,
1529
+ noNetworkBlocks: "Aún no se han registrado bloqueos de red.",
1530
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
1531
+ return `- ${new Date(time).toISOString()} ${action} ${`${tool}${attributed ? "" : " (sin atribuir)"}`} → ${`${scheme ?? "?"}://${domain}${port === void 0 ? "" : `:${port}`}`}${matched ? ruleIndex === void 0 ? "" : ` (regla ${ruleIndex + 1})` : " (modo predeterminado)"}${reason === void 0 ? "" : `: ${short(reason)}`}`;
1532
+ }
1009
1533
  };
1010
1534
  const PT = {
1011
1535
  rulesHeader: (count, sources, cwd) => `Regras de permissão: ${count} regra(s) de ${sources.join(", ")} (workspace ${cwd}).`,
@@ -1016,7 +1540,7 @@ const PT = {
1016
1540
  lastReloadWarning: (error) => `Aviso: a última recarga falhou (${error}); as regras listadas são as anteriores.`,
1017
1541
  dryRunNotice: "Modo simulação (enforce: false): acertos deny/ask são apenas registrados na auditoria — todas as chamadas passam.",
1018
1542
  unknownArg: (arg) => `Argumento de /rules desconhecido "${arg}". ${PT.usage}`,
1019
- usage: "Uso: /rules [list | reload | decisions [n] | test <ferramenta> <json-args>]",
1543
+ usage: "Uso: /rules [list | reload | network | decisions [n] | test <ferramenta> <json-args>]",
1020
1544
  decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisão(ões) de permissão desta sessão:`,
1021
1545
  noDecisions: "Esta sessão ainda não registrou decisões de permissão.",
1022
1546
  auditDisabledNotice: "A auditoria do registro da sessão está desativada neste host: seu Session.append é anterior ao marcador ignorable e tornaria as sessões irrecuperáveis em outros hosts (defina allowUnmarkedAudit: true para reativar).",
@@ -1032,7 +1556,14 @@ const PT = {
1032
1556
  testBadPlatform: (value) => `Plataforma desconhecida "${value}" em --platform: esperava-se uma de aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${PT.testUsage}`,
1033
1557
  testUsage: "Uso: /rules test [--cwd <dir>] [--env CHAVE=VALOR]... [--agent <seletor>]... [--platform <nome>] <ferramenta> <json-args>, ex.: /rules test bash {\"command\":\"git push origin main\"}",
1034
1558
  unreachableWarning: (numbers) => `Aviso: ${numbers.length > 1 ? "as regras" : "a regra"} ${numbers.join(", ")} ${numbers.length > 1 ? "são inalcançáveis" : "é inalcançável"} (encoberta por uma regra geral anterior).`,
1035
- emptySource: "(sem arquivo de regras — conjunto vazio)"
1559
+ emptySource: "(sem arquivo de regras — conjunto vazio)",
1560
+ networkDisabled: "Política de rede desativada (network.enabled: false): sem proxy, sem padrões de modo para ferramentas web.",
1561
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Política de rede: modo ${mode}${sandboxMode === void 0 ? "" : ` (preset sandbox ${sandboxMode})`}${configuredMode === "auto" ? "" : ` (configurado ${configuredMode})`}; proxy ${proxyActive ? `ativo em 127.0.0.1:${proxyPort}` : "INATIVO (falha de bind — a política de rede do shell não está ativa)"}.`,
1562
+ networkCounters: (denied, askBlocked) => `Bloqueios: ${denied} negados, ${askBlocked} bloqueados por aprovação.`,
1563
+ noNetworkBlocks: "Nenhum bloqueio de rede registrado ainda.",
1564
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
1565
+ return `- ${new Date(time).toISOString()} ${action} ${`${tool}${attributed ? "" : " (não atribuído)"}`} → ${`${scheme ?? "?"}://${domain}${port === void 0 ? "" : `:${port}`}`}${matched ? ruleIndex === void 0 ? "" : ` (regra ${ruleIndex + 1})` : " (modo padrão)"}${reason === void 0 ? "" : `: ${short(reason)}`}`;
1566
+ }
1036
1567
  };
1037
1568
  const HI = {
1038
1569
  rulesHeader: (count, sources, cwd) => `अनुमति नियम: ${count} नियम, स्रोत ${sources.join(", ")} (कार्यक्षेत्र ${cwd})।`,
@@ -1043,7 +1574,7 @@ const HI = {
1043
1574
  lastReloadWarning: (error) => `चेतावनी: अंतिम पुनः लोड विफल रहा (${error}); ऊपर सूचीबद्ध नियम पिछले वाले हैं।`,
1044
1575
  dryRunNotice: "ड्राई-रन मोड (enforce: false): deny/ask हिट केवल ऑडिट में दर्ज होते हैं — हर कॉल पास हो जाती है।",
1045
1576
  unknownArg: (arg) => `अज्ञात /rules तर्क "${arg}"। ${HI.usage}`,
1046
- usage: "उपयोग: /rules [list | reload | decisions [n] | test <टूल> <json-args>]",
1577
+ usage: "उपयोग: /rules [list | reload | network | decisions [n] | test <टूल> <json-args>]",
1047
1578
  decisionsHeader: (shown, total) => `इस सत्र के अंतिम ${shown}/${total} अनुमति निर्णय:`,
1048
1579
  noDecisions: "इस सत्र में अभी कोई अनुमति निर्णय दर्ज नहीं है।",
1049
1580
  auditDisabledNotice: "इस होस्ट पर सत्र-लॉग ऑडिट अक्षम है: इसका Session.append ignorable मार्कर से पुराना है और सत्र अन्य होस्ट पर अप्राप्य बन जाते (पुनः चालू करने के लिए allowUnmarkedAudit: true सेट करें)।",
@@ -1059,7 +1590,14 @@ const HI = {
1059
1590
  testBadPlatform: (value) => `अज्ञात प्लेटफ़ॉर्म "${value}" (--platform): aix, android, darwin, freebsd, linux, openbsd, sunos, win32 में से एक अपेक्षित। ${HI.testUsage}`,
1060
1591
  testUsage: "उपयोग: /rules test [--cwd <dir>] [--env KEY=मान]... [--agent <चयनकर्ता>]... [--platform <नाम>] <टूल> <json-args>, जैसे /rules test bash {\"command\":\"git push origin main\"}",
1061
1592
  unreachableWarning: (numbers) => `चेतावनी: नियम ${numbers.join(", ")} अप्राप्य हैं (पहले वाले सर्व-मिलान नियम से ढके हुए)।`,
1062
- emptySource: "(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)"
1593
+ emptySource: "(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)",
1594
+ networkDisabled: "नेटवर्क नीति अक्षम (network.enabled: false): कोई प्रॉक्सी नहीं, कोई वेब-टूल मोड डिफ़ॉल्ट नहीं।",
1595
+ networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `नेटवर्क नीति: मोड ${mode}${sandboxMode === void 0 ? "" : ` (सैंडबॉक्स प्रीसेट ${sandboxMode})`}${configuredMode === "auto" ? "" : ` (कॉन्फ़िगर किया ${configuredMode})`}; प्रॉक्सी ${proxyActive ? `सक्रिय 127.0.0.1:${proxyPort} पर` : "निष्क्रिय (बाइंड विफल — शेल नेटवर्क नीति लागू नहीं)"}।`,
1596
+ networkCounters: (denied, askBlocked) => `ब्लॉक: ${denied} अस्वीकृत, ${askBlocked} अनुमोदन-ब्लॉक।`,
1597
+ noNetworkBlocks: "अभी तक कोई नेटवर्क ब्लॉक दर्ज नहीं हुआ।",
1598
+ networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
1599
+ return `- ${new Date(time).toISOString()} ${action} ${`${tool}${attributed ? "" : " (असाइन नहीं)"}`} → ${`${scheme ?? "?"}://${domain}${port === void 0 ? "" : `:${port}`}`}${matched ? ruleIndex === void 0 ? "" : ` (नियम ${ruleIndex + 1})` : " (मोड डिफ़ॉल्ट)"}${reason === void 0 ? "" : `: ${short(reason)}`}`;
1600
+ }
1063
1601
  };
1064
1602
  /** The localized prose tables by language. */
1065
1603
  const UI_PROSE = {
@@ -1080,6 +1618,11 @@ const DESCRIBE_TOKENS = {
1080
1618
  absent: "absent",
1081
1619
  when: "when",
1082
1620
  platform: "platform",
1621
+ network: "network",
1622
+ domains: "domains",
1623
+ ips: "ips",
1624
+ ports: "ports",
1625
+ schemes: "schemes",
1083
1626
  disabled: "disabled",
1084
1627
  tags: "tags",
1085
1628
  src: "src"
@@ -1093,6 +1636,11 @@ const DESCRIBE_TOKENS = {
1093
1636
  absent: "缺省键",
1094
1637
  when: "条件",
1095
1638
  platform: "平台",
1639
+ network: "网络",
1640
+ domains: "域名",
1641
+ ips: "IP",
1642
+ ports: "端口",
1643
+ schemes: "协议",
1096
1644
  disabled: "已禁用",
1097
1645
  tags: "标签",
1098
1646
  src: "来源"
@@ -1106,6 +1654,11 @@ const DESCRIBE_TOKENS = {
1106
1654
  absent: "ausentes",
1107
1655
  when: "cuándo",
1108
1656
  platform: "plataforma",
1657
+ network: "red",
1658
+ domains: "dominios",
1659
+ ips: "IP",
1660
+ ports: "puertos",
1661
+ schemes: "esquemas",
1109
1662
  disabled: "deshabilitada",
1110
1663
  tags: "etiquetas",
1111
1664
  src: "origen"
@@ -1119,6 +1672,11 @@ const DESCRIBE_TOKENS = {
1119
1672
  absent: "ausentes",
1120
1673
  when: "quando",
1121
1674
  platform: "plataforma",
1675
+ network: "rede",
1676
+ domains: "domínios",
1677
+ ips: "IP",
1678
+ ports: "portas",
1679
+ schemes: "esquemas",
1122
1680
  disabled: "desativada",
1123
1681
  tags: "etiquetas",
1124
1682
  src: "origem"
@@ -1132,12 +1690,410 @@ const DESCRIBE_TOKENS = {
1132
1690
  absent: "अनुपस्थित",
1133
1691
  when: "शर्त",
1134
1692
  platform: "प्लेटफ़ॉर्म",
1693
+ network: "नेटवर्क",
1694
+ domains: "डोमेन",
1695
+ ips: "IP",
1696
+ ports: "पोर्ट",
1697
+ schemes: "स्कीम",
1135
1698
  disabled: "अक्षम",
1136
1699
  tags: "टैग",
1137
1700
  src: "स्रोत"
1138
1701
  }
1139
1702
  };
1140
1703
  //#endregion
1704
+ //#region src/proxy.ts
1705
+ /**
1706
+ * The plugin's built-in local HTTP/CONNECT proxy: the process-level
1707
+ * network gate for shell subprocess traffic. Bash/pwsh children inherit
1708
+ * `HTTP(S)_PROXY`/`ALL_PROXY` environment variables pointing here, and the
1709
+ * proxy adjudicates EVERY connection target against the loaded network
1710
+ * rules and the active policy mode — the domain decision lives in the
1711
+ * proxy layer, not in per-command wrappers.
1712
+ *
1713
+ * Boundaries (documented in the README): DSH's official `ctx.sandbox`
1714
+ * enforces FILE effects only, so network interception is entirely this
1715
+ * plugin's job; the proxy has no session context, so an `ask` decision at
1716
+ * the proxy layer cannot ride the interactive approval seam — it blocks
1717
+ * the connection with a structured message and is audit-logged instead
1718
+ * (web tools get the real approval seam at `tools/pre-execute`).
1719
+ * @module dsh-permission-rules/proxy
1720
+ */
1721
+ /** Proxy environment variable names the injector sets/restores (uppercase + lowercase for mixed-ecosystem CLIs). */
1722
+ const PROXY_ENV_NAMES = [
1723
+ "HTTP_PROXY",
1724
+ "HTTPS_PROXY",
1725
+ "ALL_PROXY",
1726
+ "http_proxy",
1727
+ "https_proxy",
1728
+ "all_proxy"
1729
+ ];
1730
+ /** NO_PROXY names cleared (or preserved) by the injector. */
1731
+ const NO_PROXY_ENV_NAMES = ["NO_PROXY", "no_proxy"];
1732
+ /**
1733
+ * The local HTTP/CONNECT policy proxy. Binds on demand (the plugin calls
1734
+ * {@link start} inside an effect and {@link close} from its disposer);
1735
+ * every live tunnel socket is tracked and destroyed on close so updates
1736
+ * and uninstalls leave no orphaned connections.
1737
+ */
1738
+ var NetworkProxy = class {
1739
+ options;
1740
+ server;
1741
+ sockets = /* @__PURE__ */ new Set();
1742
+ recent = [];
1743
+ stats = {
1744
+ denied: 0,
1745
+ askBlocked: 0
1746
+ };
1747
+ actualPort = 0;
1748
+ constructor(options) {
1749
+ this.options = options;
1750
+ }
1751
+ /** The bound port (valid after {@link start} resolves). */
1752
+ get port() {
1753
+ return this.actualPort;
1754
+ }
1755
+ /** Deny/ask blocks recorded since mount, newest first (settings-page list). */
1756
+ recentBlocks() {
1757
+ return this.recent;
1758
+ }
1759
+ /** Cumulative block counters. */
1760
+ blockStats() {
1761
+ return { ...this.stats };
1762
+ }
1763
+ /** Bind the server and return the actual port. */
1764
+ start() {
1765
+ const server = createServer((req, res) => {
1766
+ this.handleRequest(req, res);
1767
+ });
1768
+ server.on("connect", (req, socket, head) => {
1769
+ this.handleConnect(req, socket, head);
1770
+ });
1771
+ server.on("error", (error) => {
1772
+ this.options.logger.warn(`permission-rules: proxy server error: ${String(error)}`);
1773
+ });
1774
+ this.server = server;
1775
+ return new Promise((resolve, reject) => {
1776
+ const onError = (error) => {
1777
+ server.off("listening", onListening);
1778
+ reject(error);
1779
+ };
1780
+ const onListening = () => {
1781
+ server.off("error", onError);
1782
+ const address = server.address();
1783
+ this.actualPort = address.port;
1784
+ resolve(address.port);
1785
+ };
1786
+ server.once("error", onError);
1787
+ server.once("listening", onListening);
1788
+ server.listen(this.options.port, this.options.bind);
1789
+ });
1790
+ }
1791
+ /** Stop the server and destroy every tunnel; resolves when sockets are closed. */
1792
+ close() {
1793
+ const server = this.server;
1794
+ this.server = void 0;
1795
+ for (const socket of this.sockets) socket.destroy();
1796
+ this.sockets.clear();
1797
+ if (server === void 0) return Promise.resolve();
1798
+ return new Promise((resolve) => {
1799
+ server.close(() => resolve());
1800
+ });
1801
+ }
1802
+ /** Plain-HTTP proxying: absolute-form requests are adjudicated and forwarded (or blocked with a structured 403). */
1803
+ async handleRequest(req, res) {
1804
+ const target = parseUrlTarget(req.url ?? "");
1805
+ if (target === void 0 || target.scheme === void 0) {
1806
+ res.writeHead(404, { "content-type": "text/plain" });
1807
+ res.end("permission-rules proxy: only absolute-form proxy requests are served here\n");
1808
+ return;
1809
+ }
1810
+ await this.forwardOrBlock(res, target, () => {
1811
+ const upstream = new URL(req.url);
1812
+ const proxyReq = (upstream.protocol === "https:" ? request$1 : request)(upstream, {
1813
+ method: req.method,
1814
+ headers: req.headers
1815
+ }, (proxyRes) => {
1816
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
1817
+ proxyRes.pipe(res);
1818
+ });
1819
+ proxyReq.on("error", (error) => {
1820
+ if (!res.headersSent) {
1821
+ res.writeHead(502, { "content-type": "text/plain" });
1822
+ res.end(`[network: upstream error] ${String(error)}\n`);
1823
+ } else res.destroy();
1824
+ });
1825
+ req.pipe(proxyReq);
1826
+ });
1827
+ }
1828
+ /** CONNECT tunneling (HTTPS and friends): adjudicate, then either 403 or an established TCP tunnel. */
1829
+ async handleConnect(req, socket, head) {
1830
+ const target = connectTarget(req.url ?? "");
1831
+ if (target === void 0) {
1832
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
1833
+ return;
1834
+ }
1835
+ const decision = await this.decideWithResolution(target);
1836
+ if (decision.action !== "allow") {
1837
+ this.recordBlock(decision, target);
1838
+ const body = blockMessage(decision);
1839
+ socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
1840
+ return;
1841
+ }
1842
+ const upstream = connect(target.port ?? 443, target.host);
1843
+ this.sockets.add(socket);
1844
+ this.sockets.add(upstream);
1845
+ const cleanup = () => {
1846
+ this.sockets.delete(socket);
1847
+ this.sockets.delete(upstream);
1848
+ };
1849
+ socket.on("close", cleanup);
1850
+ upstream.on("close", cleanup);
1851
+ upstream.on("error", () => socket.destroy());
1852
+ socket.on("error", () => upstream.destroy());
1853
+ upstream.once("connect", () => {
1854
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
1855
+ if (head.length > 0) upstream.write(head);
1856
+ upstream.pipe(socket);
1857
+ socket.pipe(upstream);
1858
+ });
1859
+ }
1860
+ /** Adjudicate one plain-HTTP request; deny/ask blocks with a structured 403 before any forwarding. */
1861
+ async forwardOrBlock(res, target, forward) {
1862
+ const decision = await this.decideWithResolution(target);
1863
+ if (decision.action !== "allow") {
1864
+ this.recordBlock(decision, target);
1865
+ const body = blockMessage(decision);
1866
+ res.writeHead(403, {
1867
+ "content-type": "text/plain; charset=utf-8",
1868
+ "content-length": Buffer.byteLength(body)
1869
+ });
1870
+ res.end(body);
1871
+ return;
1872
+ }
1873
+ forward();
1874
+ }
1875
+ /** Resolve a hostname once so `ips`-scoped rules see the real addresses, then decide. */
1876
+ async decideWithResolution(target) {
1877
+ if (!isIpLiteral(target.host)) try {
1878
+ const resolved = (await lookup(target.host, {
1879
+ all: true,
1880
+ verbatim: true
1881
+ })).map((entry) => entry.address);
1882
+ if (resolved.length > 0) return this.options.decide({
1883
+ ...target,
1884
+ ips: [...target.ips, ...resolved]
1885
+ });
1886
+ } catch {}
1887
+ return this.options.decide(target);
1888
+ }
1889
+ /** Record a block: counters, recent ring, and the runtime hook (logger + session audit). */
1890
+ recordBlock(decision, target) {
1891
+ const attribution = this.options.attribution?.();
1892
+ const record = {
1893
+ time: Date.now(),
1894
+ tool: attribution?.tool ?? "subprocess",
1895
+ attributed: attribution !== void 0,
1896
+ ...attribution?.callId !== void 0 ? { callId: attribution.callId } : {},
1897
+ domain: target.host,
1898
+ ...target.scheme !== void 0 ? { scheme: target.scheme } : {},
1899
+ ...target.port !== void 0 ? { port: target.port } : {},
1900
+ action: decision.action === "ask" ? "ask" : "deny",
1901
+ mode: decision.mode,
1902
+ matched: decision.matched,
1903
+ source: decision.source ?? "",
1904
+ ...decision.ruleIndex !== void 0 ? { ruleIndex: decision.ruleIndex } : {},
1905
+ ...decision.rule !== void 0 ? { reason: decision.rule.reason } : {}
1906
+ };
1907
+ if (record.action === "deny") this.stats.denied += 1;
1908
+ else this.stats.askBlocked += 1;
1909
+ this.recent.unshift(record);
1910
+ if (this.recent.length > this.options.maxRecent) this.recent.length = this.options.maxRecent;
1911
+ this.options.logger.warn(`permission-rules: network ${record.action === "deny" ? "denied" : "ask-blocked"} ${target.scheme ?? "?"}://${target.host}${target.port !== void 0 ? `:${target.port}` : ""} (mode ${decision.mode}${decision.matched ? `, rule ${(decision.ruleIndex ?? 0) + 1}` : ", mode default"})`);
1912
+ try {
1913
+ this.options.onBlock?.(record, attribution);
1914
+ } catch (error) {
1915
+ this.options.logger.warn(`permission-rules: network block hook failed: ${String(error)}`);
1916
+ }
1917
+ }
1918
+ };
1919
+ /** Parse a CONNECT authority (`host:port`) into an https target with an explicit port. */
1920
+ function connectTarget(authority) {
1921
+ const colon = authority.lastIndexOf(":");
1922
+ if (colon <= 0) return void 0;
1923
+ const host = authority.slice(0, colon).replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
1924
+ const port = Number(authority.slice(colon + 1));
1925
+ if (host.length === 0 || !Number.isInteger(port) || port < 1 || port > 65535) return void 0;
1926
+ return {
1927
+ scheme: "https",
1928
+ host,
1929
+ port,
1930
+ ips: isIpLiteral(host) ? [host] : []
1931
+ };
1932
+ }
1933
+ /**
1934
+ * Inject the proxy environment variables for subprocesses and return a
1935
+ * disposer restoring every previous value exactly (so updates and
1936
+ * uninstalls leave the host environment untouched). `noProxy: 'clear'`
1937
+ * empties NO_PROXY so the policy cannot be bypassed through ambient
1938
+ * exclusions; `preserve` keeps ambient values.
1939
+ *
1940
+ * The snapshot pass covers EVERY name BEFORE any write: on Windows
1941
+ * `process.env` is case-insensitive, so the uppercase/lowercase spellings
1942
+ * share one variable — writing `HTTP_PROXY` mid-loop would otherwise
1943
+ * poison the later snapshot of `http_proxy` and make the restore write
1944
+ * the proxy address back over the original.
1945
+ * @param port - the bound proxy port.
1946
+ * @param noProxy - the configured NO_PROXY handling.
1947
+ * @returns the restore disposer.
1948
+ */
1949
+ function injectProxyEnv(port, noProxy) {
1950
+ const previous = /* @__PURE__ */ new Map();
1951
+ const value = `http://127.0.0.1:${port}`;
1952
+ for (const name of PROXY_ENV_NAMES) previous.set(name, process.env[name]);
1953
+ if (noProxy === "clear") for (const name of NO_PROXY_ENV_NAMES) previous.set(name, process.env[name]);
1954
+ for (const name of PROXY_ENV_NAMES) process.env[name] = value;
1955
+ if (noProxy === "clear") for (const name of NO_PROXY_ENV_NAMES) process.env[name] = "";
1956
+ return () => {
1957
+ for (const name of PROXY_ENV_NAMES) {
1958
+ const old = previous.get(name);
1959
+ if (old === void 0) delete process.env[name];
1960
+ else process.env[name] = old;
1961
+ }
1962
+ if (noProxy === "clear") for (const name of NO_PROXY_ENV_NAMES) {
1963
+ const old = previous.get(name);
1964
+ if (old === void 0) delete process.env[name];
1965
+ else process.env[name] = old;
1966
+ }
1967
+ };
1968
+ }
1969
+ //#endregion
1970
+ //#region src/remote-service.ts
1971
+ /**
1972
+ * The settings-page Remote service: exposes the `permissionRules` Typert
1973
+ * namespace (network snapshot + rule-file read/save/reload) to the
1974
+ * browser half. All state lives in the {@link PermissionRulesRuntime}
1975
+ * this service delegates to; the service itself only bridges the wire
1976
+ * vocabulary. The rule editor only ever touches KNOWN rule sources —
1977
+ * arbitrary paths are refused by the runtime.
1978
+ * @module dsh-permission-rules/remote-service
1979
+ */
1980
+ /**
1981
+ * The Typert Remote service behind the settings page. Registered through
1982
+ * `ctx.plugin(PermissionRulesRemoteService, { runtime })` in
1983
+ * {@link apply}; the typert gateway binds its public methods to the
1984
+ * invocation descriptors in `./wire.ts`.
1985
+ */
1986
+ var PermissionRulesRemoteService = class extends TypertRemoteService {
1987
+ config;
1988
+ static inject = [];
1989
+ constructor(ctx, config) {
1990
+ super(ctx, "permissionRules");
1991
+ this.config = config;
1992
+ }
1993
+ /** The settings-page snapshot: mode mapping, proxy liveness, counters, recent blocks, editable rule sources. */
1994
+ networkStatus() {
1995
+ const snapshot = this.config.runtime.networkSnapshot();
1996
+ const sources = this.config.runtime.knownRuleSources().map((path) => {
1997
+ const owner = this.config.runtime.sourceOwner(path);
1998
+ return {
1999
+ path,
2000
+ exists: existsSync(path),
2001
+ cwd: owner ?? null
2002
+ };
2003
+ });
2004
+ return {
2005
+ enabled: snapshot.enabled,
2006
+ mode: snapshot.mode,
2007
+ configuredMode: snapshot.configuredMode,
2008
+ sandboxMode: snapshot.sandboxMode ?? null,
2009
+ proxyPort: snapshot.proxyPort,
2010
+ proxyActive: snapshot.proxyActive,
2011
+ denied: snapshot.denied,
2012
+ askBlocked: snapshot.askBlocked,
2013
+ recent: snapshot.recent.map((block) => ({
2014
+ time: block.time,
2015
+ tool: block.tool,
2016
+ attributed: block.attributed,
2017
+ domain: block.domain,
2018
+ scheme: block.scheme ?? null,
2019
+ port: block.port ?? null,
2020
+ action: block.action,
2021
+ mode: block.mode,
2022
+ matched: block.matched,
2023
+ source: block.source,
2024
+ ruleIndex: block.ruleIndex ?? null,
2025
+ reason: block.reason ?? null
2026
+ })),
2027
+ sources
2028
+ };
2029
+ }
2030
+ /** Read one known rule file for the editor. */
2031
+ rulesRead(path) {
2032
+ const result = this.config.runtime.readRuleFile(path);
2033
+ return {
2034
+ path: result.path,
2035
+ exists: result.exists,
2036
+ text: result.text,
2037
+ error: result.error ?? null
2038
+ };
2039
+ }
2040
+ /** Validate and write one known rule file; invalid edits are rejected before anything touches disk. */
2041
+ rulesSave(path, text) {
2042
+ const result = this.config.runtime.saveRuleFile(path, text);
2043
+ return {
2044
+ ok: result.ok,
2045
+ error: result.error ?? null,
2046
+ reloaded: result.reloaded ?? null
2047
+ };
2048
+ }
2049
+ /** Re-read every cached workspace chain (after an external edit). */
2050
+ reload() {
2051
+ try {
2052
+ this.config.runtime.reloadAll();
2053
+ return {
2054
+ ok: true,
2055
+ error: null
2056
+ };
2057
+ } catch (error) {
2058
+ return {
2059
+ ok: false,
2060
+ error: error instanceof Error ? error.message : String(error)
2061
+ };
2062
+ }
2063
+ }
2064
+ };
2065
+ //#endregion
2066
+ //#region src/settings.ts
2067
+ /** The settings namespace this plugin owns (kebab-case, per the provider contract). */
2068
+ const SETTINGS_NAMESPACE = "permission-rules";
2069
+ /**
2070
+ * Register the settings section and bind the runtime's live config
2071
+ * source. Runs only where a settings service is composed.
2072
+ * @param ctx - the plugin context.
2073
+ * @param runtime - the runtime whose config source follows the section.
2074
+ * @param entry - the composition entry config (the section's `base` layer).
2075
+ */
2076
+ function attachSettingsSection(ctx, runtime, entry) {
2077
+ ctx.inject(["settings"], (scope) => {
2078
+ const settings = scope.get("settings");
2079
+ if (settings === void 0) return;
2080
+ const registration = settings.register(SETTINGS_NAMESPACE, Config, {
2081
+ base: entry,
2082
+ expose: true,
2083
+ applies: "live",
2084
+ validate: (value) => {
2085
+ resolveConfig(value);
2086
+ }
2087
+ });
2088
+ runtime.setConfigSource(() => resolveConfig(registration.get()));
2089
+ registration.watch((next, prev) => {
2090
+ const nextConfig = resolveConfig(next);
2091
+ const prevConfig = resolveConfig(prev);
2092
+ if (nextConfig.network.proxyPort !== prevConfig.network.proxyPort || nextConfig.network.proxyBind !== prevConfig.network.proxyBind || nextConfig.network.injectEnv !== prevConfig.network.injectEnv || nextConfig.network.noProxy !== prevConfig.network.noProxy) runtime.onNetworkConfigChanged();
2093
+ });
2094
+ });
2095
+ }
2096
+ //#endregion
1141
2097
  //#region src/events.ts
1142
2098
  /**
1143
2099
  * Whether an `append` call actually honored the `ignorable` marker: the
@@ -1165,13 +2121,16 @@ function isMarkedAuditEvent(result) {
1165
2121
  * registration is an effect.
1166
2122
  * @module dsh-permission-rules/runtime
1167
2123
  */
2124
+ /** The network-capable tools whose unlisted calls fall back to the mode default at `tools/pre-execute`. */
2125
+ const WEB_TOOLS = ["web_fetch", "web_search"];
2126
+ /** Stale in-flight entries are dropped after this many milliseconds (attribution is best-effort). */
2127
+ const IN_FLIGHT_TTL_MS = 6e5;
1168
2128
  /**
1169
2129
  * State and behavior. One instance per plugin mount; disposals are owned by
1170
- * the watcher/timer effects registered in {@link apply}.
2130
+ * the watcher/timer/proxy effects registered in {@link apply}.
1171
2131
  */
1172
2132
  var PermissionRulesRuntime = class {
1173
2133
  ctx;
1174
- config;
1175
2134
  /** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
1176
2135
  byCwd = /* @__PURE__ */ new Map();
1177
2136
  /** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
@@ -1180,17 +2139,34 @@ var PermissionRulesRuntime = class {
1180
2139
  timers = /* @__PURE__ */ new Map();
1181
2140
  /** Whether the host honors the audit envelope's `ignorable` marker: unknown until the first decision (or peer-version check). */
1182
2141
  auditSupport = "unknown";
2142
+ /** In-flight bash/pwsh executions (insertion order = start order), for proxy-block attribution. */
2143
+ inFlight = /* @__PURE__ */ new Map();
2144
+ /** The network proxy, when the policy is enabled and mounted. */
2145
+ networkProxy;
2146
+ /** Restores the injected proxy environment (set after a successful bind). */
2147
+ envRestore;
2148
+ /** The authoritative config source: the composition entry, replaced by the settings scope while attached. */
2149
+ configSource;
1183
2150
  constructor(ctx, config) {
1184
2151
  this.ctx = ctx;
1185
- this.config = config;
2152
+ this.configSource = () => config;
2153
+ }
2154
+ /** The currently authoritative config (settings scope or composition entry). */
2155
+ get config() {
2156
+ return this.configSource();
2157
+ }
2158
+ /** Rebind the config source (the settings section hooks call this on attach/detach/change). */
2159
+ setConfigSource(source) {
2160
+ this.configSource = source;
1186
2161
  }
1187
2162
  /** The shared compile options derived from config. */
1188
2163
  compileOptions() {
2164
+ const config = this.configSource();
1189
2165
  return {
1190
- patternMode: this.config.patternMode,
1191
- maxRules: this.config.maxRules,
1192
- maxGlobStars: this.config.maxGlobStars,
1193
- caseInsensitivePaths: this.config.caseInsensitivePaths
2166
+ patternMode: config.patternMode,
2167
+ maxRules: config.maxRules,
2168
+ maxGlobStars: config.maxGlobStars,
2169
+ caseInsensitivePaths: config.caseInsensitivePaths
1194
2170
  };
1195
2171
  }
1196
2172
  /**
@@ -1382,11 +2358,16 @@ var PermissionRulesRuntime = class {
1382
2358
  * The `tools/pre-execute` listener. A deny/ask hit returns the decision
1383
2359
  * (first match wins, short-circuiting downstream listeners); an allow hit
1384
2360
  * and a passthrough MUST delegate via `next()` so later listeners keep
1385
- * their say. Under `enforce: false` (dry-run) a deny/ask hit also
1386
- * delegates — the record keeps the would-be action with `dryRun: true`
1387
- * and the actual downstream outcome. Audit is appended once the final
1388
- * outcome is known, so the recorded `outcome` matches what the waterfall
1389
- * settled on.
2361
+ * their say. Network-scoped rule hits on tool calls carry the structured
2362
+ * `[network: …]` marker; when NO rule matches a web tool
2363
+ * (`web_fetch`/`web_search`) the network mode default applies (deny-all
2364
+ * denies, whitelist asks/denies, allow-all passes) — shell tools are
2365
+ * NOT gated here, the proxy enforces their traffic per connection.
2366
+ * Under `enforce: false` (dry-run) a deny/ask hit also delegates — the
2367
+ * record keeps the would-be action with `dryRun: true` and the actual
2368
+ * downstream outcome. Audit is appended once the final outcome is
2369
+ * known, so the recorded `outcome` matches what the waterfall settled
2370
+ * on.
1390
2371
  * @param exec - the pending call (name, parsed arguments, caller agent).
1391
2372
  * @param next - the downstream chain.
1392
2373
  * @returns the pre-execute decision.
@@ -1395,26 +2376,52 @@ var PermissionRulesRuntime = class {
1395
2376
  const cwd = exec.agent?.session.header.cwd ?? process.cwd();
1396
2377
  const loaded = this.rulesFor(cwd);
1397
2378
  const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec));
1398
- if (hit === void 0 || hit.rule.action === "allow") {
1399
- const decision = await next();
1400
- this.audit(exec, loaded, hit, decision.kind);
1401
- return decision;
1402
- }
1403
- if (!this.config.enforce) {
1404
- const decision = await next();
1405
- this.audit(exec, loaded, hit, decision.kind, true);
1406
- return decision;
1407
- }
1408
- const outcome = hit.rule.action;
1409
- this.audit(exec, loaded, hit, outcome);
1410
- if (outcome === "deny") return {
1411
- kind: "deny",
1412
- reason: hit.rule.reason
1413
- };
1414
- return {
1415
- kind: "ask",
1416
- reason: hit.rule.reason
1417
- };
2379
+ if (hit !== void 0 && hit.rule.action !== "allow") {
2380
+ const reason = hit.rule.network !== void 0 ? `${hit.rule.action === "deny" ? "[network: denied]" : "[network: approval required]"} ${hit.rule.reason}` : hit.rule.reason;
2381
+ if (!this.config.enforce) {
2382
+ const decision = await next();
2383
+ this.audit(exec, loaded, hit, decision.kind, true);
2384
+ return decision;
2385
+ }
2386
+ const outcome = hit.rule.action;
2387
+ this.audit(exec, loaded, hit, outcome);
2388
+ return {
2389
+ kind: outcome,
2390
+ reason
2391
+ };
2392
+ }
2393
+ if (hit === void 0) {
2394
+ const fallback = this.webToolFallback(exec);
2395
+ if (fallback !== void 0) {
2396
+ if (!this.config.enforce) {
2397
+ const decision = await next();
2398
+ this.audit(exec, loaded, void 0, decision.kind, true, fallback);
2399
+ return decision;
2400
+ }
2401
+ this.audit(exec, loaded, void 0, fallback, false, fallback);
2402
+ return {
2403
+ kind: fallback,
2404
+ reason: blockMessage(defaultDecision(this.resolveNetworkMode(exec.agent?.session).mode, this.config.network.unlisted))
2405
+ };
2406
+ }
2407
+ }
2408
+ if (isShellTool(exec.name)) this.markShell(exec);
2409
+ const decision = await next();
2410
+ this.audit(exec, loaded, hit, decision.kind);
2411
+ return decision;
2412
+ }
2413
+ /**
2414
+ * The mode-default decision for an unlisted web-tool call, or undefined
2415
+ * when the policy is disabled, the tool is not a web tool, or the mode
2416
+ * allows. Shell tools always yield undefined here (the proxy decides
2417
+ * their traffic per connection).
2418
+ */
2419
+ webToolFallback(exec) {
2420
+ const network = this.config.network;
2421
+ if (!network.enabled || isShellTool(exec.name) || !WEB_TOOLS.includes(exec.name)) return void 0;
2422
+ const { mode } = this.resolveNetworkMode(exec.agent?.session);
2423
+ const fallback = defaultDecision(mode, network.unlisted);
2424
+ return fallback.action === "allow" ? void 0 : fallback.action;
1418
2425
  }
1419
2426
  /**
1420
2427
  * Append the log-only `permissionRules/decision` audit event for every
@@ -1428,17 +2435,19 @@ var PermissionRulesRuntime = class {
1428
2435
  * `allowUnmarkedAudit: true` opts back in. `source` names the matched
1429
2436
  * rule's file, or the nearest effective file on a passthrough; `cwd`
1430
2437
  * names the workspace the rules were resolved for; `dryRun` marks
1431
- * would-be deny/ask hits under `enforce: false`. Agentless calls have no
1432
- * session to audit; append failures are contained so an audit hiccup can
1433
- * never change a permission decision.
2438
+ * would-be deny/ask hits under `enforce: false`; `modeDefault` records a
2439
+ * network mode-default decision on a web tool (no rule fired).
2440
+ * Agentless calls have no session to audit; append failures are
2441
+ * contained so an audit hiccup can never change a permission decision.
1434
2442
  * @param exec - the pending call.
1435
2443
  * @param loaded - the rules in effect.
1436
2444
  * @param hit - the first matching rule, or undefined for passthrough.
1437
2445
  * @param outcome - the final pre-execute decision.
1438
2446
  * @param dryRun - mark the record as a would-be decision (dry-run mode).
2447
+ * @param modeDefault - the network mode-default action when no rule fired.
1439
2448
  */
1440
- audit(exec, loaded, hit, outcome, dryRun = false) {
1441
- if (this.config.audit === "hits" && hit === void 0) return;
2449
+ audit(exec, loaded, hit, outcome, dryRun = false, modeDefault) {
2450
+ if (this.config.audit === "hits" && hit === void 0 && modeDefault === void 0) return;
1442
2451
  const agent = exec.agent;
1443
2452
  if (agent === void 0) return;
1444
2453
  if (this.auditSupport === "unsupported") return;
@@ -1451,34 +2460,329 @@ var PermissionRulesRuntime = class {
1451
2460
  }
1452
2461
  }
1453
2462
  try {
2463
+ const action = hit === void 0 ? modeDefault ?? "passthrough" : hit.rule.action;
1454
2464
  const result = this.appendAudit(agent, {
1455
2465
  toolName: exec.name,
1456
2466
  callId: exec.callId,
1457
2467
  source: hit === void 0 ? loaded.sources[0] ?? "" : loaded.sources[hit.rule.sourceIndex] ?? "",
1458
- action: hit === void 0 ? "passthrough" : hit.rule.action,
2468
+ action,
1459
2469
  outcome,
1460
2470
  cwd: loaded.cwd,
1461
2471
  ...hit !== void 0 ? {
1462
2472
  ruleIndex: hit.ruleIndex,
1463
2473
  reason: hit.rule.reason
1464
2474
  } : {},
2475
+ ...modeDefault !== void 0 ? { reason: `${modeDefault === "deny" ? "[network: denied]" : "[network: approval required]"} network mode default (no rule matched)` } : {},
1465
2476
  ...dryRun ? { dryRun: true } : {}
1466
2477
  });
1467
- if (this.auditSupport === "unknown" && !this.config.allowUnmarkedAudit) {
1468
- if (isMarkedAuditEvent(result)) this.auditSupport = "supported";
1469
- else {
1470
- this.auditSupport = "unsupported";
1471
- this.warnUnmarkedAuditHost();
1472
- }
1473
- }
2478
+ this.probeAuditResult(result);
1474
2479
  } catch (error) {
1475
2480
  this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`);
1476
2481
  }
1477
2482
  }
2483
+ /** After the first append, probe the returned envelope for the ignorable marker (host capability detection). */
2484
+ probeAuditResult(result) {
2485
+ if (this.auditSupport === "unknown" && !this.config.allowUnmarkedAudit) {
2486
+ if (isMarkedAuditEvent(result)) this.auditSupport = "supported";
2487
+ else {
2488
+ this.auditSupport = "unsupported";
2489
+ this.warnUnmarkedAuditHost();
2490
+ }
2491
+ }
2492
+ }
1478
2493
  /** Append one audit event through the session surface; the probe seam for host-capability detection. */
1479
2494
  appendAudit(agent, data) {
1480
2495
  return agent.session.append("permissionRules/decision", data, { ignorable: true });
1481
2496
  }
2497
+ /**
2498
+ * Resolve the network policy mode. An explicit config mode wins; `auto`
2499
+ * maps the official sandbox preset (`read-only` → deny-all,
2500
+ * `workspace-write` → whitelist, `danger-full-access` → allow-all) with
2501
+ * `network.autoFallback` covering hosts without the sandbox-policy
2502
+ * service (rc.6 and friends stay permissive until configured). For web
2503
+ * tools the SESSION's resolved mode is used; the proxy resolves without
2504
+ * a session (its connections carry no session context).
2505
+ * @param session - optional session whose override outranks the default.
2506
+ * @returns the resolved mode plus the sandbox preset it came from.
2507
+ */
2508
+ resolveNetworkMode(session) {
2509
+ const cfg = this.config.network;
2510
+ if (cfg.mode !== "auto") return {
2511
+ mode: cfg.mode,
2512
+ sandboxMode: void 0
2513
+ };
2514
+ const policy = this.ctx.get("sandboxPolicy");
2515
+ if (policy === void 0) return {
2516
+ mode: cfg.autoFallback,
2517
+ sandboxMode: void 0
2518
+ };
2519
+ const sandboxMode = session === void 0 ? policy.defaultMode : policy.resolve?.({ session })?.mode ?? policy.defaultMode;
2520
+ return {
2521
+ mode: networkModeForSandbox(sandboxMode, cfg.autoFallback),
2522
+ sandboxMode
2523
+ };
2524
+ }
2525
+ /**
2526
+ * The proxy-layer decision for one connection target: first-match
2527
+ * network rules across every loaded workspace chain (insertion order),
2528
+ * then the mode default. Shell subprocess traffic counts as `bash`/
2529
+ * `pwsh` tool candidates; loopback handling follows config.
2530
+ */
2531
+ decideProxyTarget(target) {
2532
+ const { mode } = this.resolveNetworkMode();
2533
+ return decideNetworkTarget(this.proxyChains(), target, {
2534
+ mode,
2535
+ unlisted: this.config.network.unlisted,
2536
+ loopback: this.config.network.loopback
2537
+ });
2538
+ }
2539
+ /** The loaded rule chains in cache order, nearest workspace first. */
2540
+ proxyChains() {
2541
+ const chains = [];
2542
+ for (const loaded of this.byCwd.values()) chains.push({
2543
+ ruleset: loaded.compiled,
2544
+ sources: loaded.sources
2545
+ });
2546
+ return chains;
2547
+ }
2548
+ /** Mark one delegated shell execution as in-flight (newest attribution wins). */
2549
+ markShell(exec) {
2550
+ this.inFlight.set(String(exec.callId), {
2551
+ tool: exec.name,
2552
+ callId: exec.callId,
2553
+ startedAt: Date.now(),
2554
+ agent: exec.agent
2555
+ });
2556
+ }
2557
+ /** Drop one shell execution when it settles. */
2558
+ unmarkShell(exec) {
2559
+ this.inFlight.delete(String(exec.callId));
2560
+ }
2561
+ /** The newest in-flight shell execution within the TTL, or undefined (best-effort attribution). */
2562
+ proxyAttribution() {
2563
+ const cutoff = Date.now() - IN_FLIGHT_TTL_MS;
2564
+ let newest;
2565
+ for (const entry of this.inFlight.values()) {
2566
+ if (entry.startedAt < cutoff) {
2567
+ this.inFlight.delete(String(entry.callId));
2568
+ continue;
2569
+ }
2570
+ if (newest === void 0 || entry.startedAt > newest.startedAt) newest = entry;
2571
+ }
2572
+ if (newest === void 0) return void 0;
2573
+ return {
2574
+ tool: newest.tool,
2575
+ ...newest.callId !== void 0 ? { callId: newest.callId } : {},
2576
+ ...newest.agent !== void 0 ? { agent: newest.agent } : {}
2577
+ };
2578
+ }
2579
+ /**
2580
+ * Audit one proxy-layer block: the in-memory record is always kept
2581
+ * (settings page), the logger always warns, and when the block can be
2582
+ * attributed to an agent the `permissionRules/network` event is appended
2583
+ * with the same ignorable-marker discipline as the decision audit.
2584
+ */
2585
+ auditNetworkBlock(record, attribution) {
2586
+ const agent = attribution?.agent;
2587
+ if (agent === void 0 || this.auditSupport === "unsupported") return;
2588
+ if (this.auditSupport === "unknown" && !this.config.allowUnmarkedAudit) {
2589
+ const version = this.peerVersion();
2590
+ if (version !== null && isUnmarkedHostVersion(version)) {
2591
+ this.auditSupport = "unsupported";
2592
+ this.warnUnmarkedAuditHost();
2593
+ return;
2594
+ }
2595
+ }
2596
+ const data = {
2597
+ kind: "block",
2598
+ tool: record.tool,
2599
+ attributed: record.attributed,
2600
+ ...record.callId !== void 0 ? { callId: record.callId } : {},
2601
+ domain: record.domain,
2602
+ ...record.scheme !== void 0 ? { scheme: record.scheme } : {},
2603
+ ...record.port !== void 0 ? { port: record.port } : {},
2604
+ action: record.action,
2605
+ mode: record.mode,
2606
+ matched: record.matched,
2607
+ source: record.source,
2608
+ ...record.ruleIndex !== void 0 ? { ruleIndex: record.ruleIndex } : {},
2609
+ ...record.reason !== void 0 ? { reason: record.reason } : {},
2610
+ time: record.time
2611
+ };
2612
+ try {
2613
+ const result = agent.session.append("permissionRules/network", data, { ignorable: true });
2614
+ this.probeAuditResult(result);
2615
+ } catch (error) {
2616
+ this.ctx.logger.warn(`permission-rules: network audit append failed: ${String(error)}`);
2617
+ }
2618
+ }
2619
+ /**
2620
+ * Mount the network proxy and the subprocess environment injection.
2621
+ * Called from {@link apply}; the proxy binds an ephemeral (or
2622
+ * configured) loopback port, the env injection only happens after a
2623
+ * successful bind, and every side effect is owned by an effect
2624
+ * disposer. A bind failure degrades loudly (file rules stay active,
2625
+ * the settings page and `/rules network` show the inactive proxy)
2626
+ * instead of taking the permission plugin down.
2627
+ */
2628
+ async attachNetworkProxy() {
2629
+ const cfg = this.config.network;
2630
+ const proxy = new NetworkProxy({
2631
+ bind: cfg.proxyBind,
2632
+ port: cfg.proxyPort,
2633
+ maxRecent: cfg.proxyMaxRecent,
2634
+ decide: (target) => this.decideProxyTarget(target),
2635
+ attribution: () => this.proxyAttribution(),
2636
+ onBlock: (record, attribution) => this.auditNetworkBlock(record, attribution),
2637
+ logger: this.ctx.logger
2638
+ });
2639
+ this.networkProxy = proxy;
2640
+ this.ctx.effect(() => () => {
2641
+ proxy.close();
2642
+ this.networkProxy = void 0;
2643
+ });
2644
+ try {
2645
+ const port = await proxy.start();
2646
+ if (cfg.injectEnv) {
2647
+ this.envRestore = injectProxyEnv(port, cfg.noProxy);
2648
+ this.ctx.effect(() => {
2649
+ const restore = this.envRestore;
2650
+ this.envRestore = void 0;
2651
+ return () => restore?.();
2652
+ });
2653
+ }
2654
+ const { mode, sandboxMode } = this.resolveNetworkMode();
2655
+ this.ctx.logger.info(`permission-rules: network proxy listening on ${cfg.proxyBind}:${port} (mode ${mode}${sandboxMode !== void 0 ? `, sandbox ${sandboxMode}` : ""})`);
2656
+ } catch (error) {
2657
+ this.ctx.logger.warn(`permission-rules: network proxy failed to bind on ${cfg.proxyBind}:${cfg.proxyPort} (${String(error)}) — shell network policy is INACTIVE; file/command rules stay active`);
2658
+ }
2659
+ }
2660
+ /**
2661
+ * Apply a live settings change to the network policy: rebind the proxy
2662
+ * when a bind/env-relevant knob changed (old proxy closed, old env
2663
+ * restored, new proxy + env installed). Web-tool gating and the decision
2664
+ * path read the config per call, so they need no rebind.
2665
+ */
2666
+ async onNetworkConfigChanged() {
2667
+ if (!this.config.network.enabled) return;
2668
+ await this.networkProxy?.close();
2669
+ this.envRestore?.();
2670
+ this.envRestore = void 0;
2671
+ await this.attachNetworkProxy();
2672
+ }
2673
+ /** The network snapshot the settings page and `/rules network` render. */
2674
+ networkSnapshot() {
2675
+ const cfg = this.config.network;
2676
+ const { mode, sandboxMode } = this.resolveNetworkMode();
2677
+ const proxy = this.networkProxy;
2678
+ const stats = proxy?.blockStats() ?? {
2679
+ denied: 0,
2680
+ askBlocked: 0
2681
+ };
2682
+ return {
2683
+ enabled: cfg.enabled,
2684
+ mode,
2685
+ configuredMode: cfg.mode,
2686
+ sandboxMode,
2687
+ proxyPort: proxy?.port ?? 0,
2688
+ proxyActive: proxy !== void 0 && proxy.port > 0,
2689
+ denied: stats.denied,
2690
+ askBlocked: stats.askBlocked,
2691
+ recent: proxy?.recentBlocks() ?? []
2692
+ };
2693
+ }
2694
+ /**
2695
+ * The rule-file paths the settings-page editor may read or write: every
2696
+ * currently loaded source plus the per-workspace project files and the
2697
+ * configured fallback (so a not-yet-existing project file can be
2698
+ * created). The editor never touches arbitrary paths.
2699
+ */
2700
+ knownRuleSources() {
2701
+ const cfg = this.config;
2702
+ const set = /* @__PURE__ */ new Set();
2703
+ for (const loaded of this.byCwd.values()) {
2704
+ for (const source of loaded.sources) set.add(source);
2705
+ if (!isAbsolute(cfg.rulesFile)) set.add(join(loaded.cwd, cfg.rulesFile));
2706
+ }
2707
+ if (cfg.fallbackPath !== void 0) set.add(isAbsolute(cfg.fallbackPath) ? cfg.fallbackPath : resolve(cfg.fallbackPath));
2708
+ if (isAbsolute(cfg.rulesFile)) set.add(cfg.rulesFile);
2709
+ return [...set];
2710
+ }
2711
+ /** Read one known rule file for the editor: `{ exists, text, error }`. */
2712
+ readRuleFile(path) {
2713
+ if (!this.knownRuleSources().includes(path)) return {
2714
+ path,
2715
+ exists: false,
2716
+ text: "",
2717
+ error: `refusing to read ${path}: not a known rule source`
2718
+ };
2719
+ if (!existsSync(path)) return {
2720
+ path,
2721
+ exists: false,
2722
+ text: ""
2723
+ };
2724
+ try {
2725
+ return {
2726
+ path,
2727
+ exists: true,
2728
+ text: readFileSync(path, "utf8")
2729
+ };
2730
+ } catch (error) {
2731
+ return {
2732
+ path,
2733
+ exists: true,
2734
+ text: "",
2735
+ error: error instanceof Error ? error.message : String(error)
2736
+ };
2737
+ }
2738
+ }
2739
+ /** The workspace a rule source belongs to (for the editor's file list), or undefined for the fallback. */
2740
+ sourceOwner(path) {
2741
+ for (const loaded of this.byCwd.values()) if (loaded.sources.includes(path) || !isAbsolute(this.config.rulesFile) && join(loaded.cwd, this.config.rulesFile) === path) return loaded.cwd;
2742
+ }
2743
+ /** Re-read every cached workspace chain (the settings-page reload action). */
2744
+ reloadAll() {
2745
+ for (const cwd of [...this.byCwd.keys()]) this.reload(cwd);
2746
+ }
2747
+ /**
2748
+ * Validate and write one known rule file. The document must parse and
2749
+ * compile (same checks as a load) BEFORE anything is written — an
2750
+ * invalid edit is rejected with its error and the file stays untouched.
2751
+ * After a successful write every cached workspace reloads so the edit
2752
+ * is in effect immediately.
2753
+ */
2754
+ saveRuleFile(path, text) {
2755
+ if (!this.knownRuleSources().includes(path)) return {
2756
+ ok: false,
2757
+ error: `refusing to write ${path}: not a known rule source`
2758
+ };
2759
+ try {
2760
+ compileRules(parseRulesDocument(text), this.compileOptions());
2761
+ } catch (error) {
2762
+ return {
2763
+ ok: false,
2764
+ error: error instanceof Error ? error.message : String(error)
2765
+ };
2766
+ }
2767
+ try {
2768
+ mkdirSync(dirname(path), { recursive: true });
2769
+ writeFileSync(path, text, "utf8");
2770
+ } catch (error) {
2771
+ return {
2772
+ ok: false,
2773
+ error: error instanceof Error ? error.message : String(error)
2774
+ };
2775
+ }
2776
+ let reloaded = 0;
2777
+ for (const cwd of [...this.byCwd.keys()]) {
2778
+ this.reload(cwd);
2779
+ reloaded += 1;
2780
+ }
2781
+ return {
2782
+ ok: true,
2783
+ reloaded
2784
+ };
2785
+ }
1482
2786
  /** The installed `@deepseek-ai/dsh-session` version, or `null` when unresolvable (falls back to the append probe). */
1483
2787
  peerVersion() {
1484
2788
  try {
@@ -1530,6 +2834,13 @@ var PermissionRulesRuntime = class {
1530
2834
  kind: "error",
1531
2835
  text: prose.unknownArg(invocation.rawInput.trim())
1532
2836
  };
2837
+ if (verb === "network") {
2838
+ if (rest.length > 0) return {
2839
+ kind: "error",
2840
+ text: prose.unknownArg(invocation.rawInput.trim())
2841
+ };
2842
+ return this.networkCommand(prose);
2843
+ }
1533
2844
  if (verb === "decisions") {
1534
2845
  if (rest.length > 1) return {
1535
2846
  kind: "error",
@@ -1693,6 +3004,26 @@ var PermissionRulesRuntime = class {
1693
3004
  text: lines.join("\n")
1694
3005
  };
1695
3006
  }
3007
+ /** Render the network policy state: mode mapping, proxy liveness, counters, recent blocks. */
3008
+ networkCommand(prose) {
3009
+ const snapshot = this.networkSnapshot();
3010
+ const lines = [];
3011
+ if (!snapshot.enabled) {
3012
+ lines.push(prose.networkDisabled);
3013
+ return {
3014
+ kind: "success",
3015
+ text: lines.join("\n")
3016
+ };
3017
+ }
3018
+ lines.push(prose.networkHeader(snapshot.mode, snapshot.sandboxMode, snapshot.configuredMode, snapshot.proxyActive, snapshot.proxyPort));
3019
+ lines.push(prose.networkCounters(snapshot.denied, snapshot.askBlocked));
3020
+ if (snapshot.recent.length === 0) lines.push(prose.noNetworkBlocks);
3021
+ else for (const block of snapshot.recent.slice(0, 10)) lines.push(prose.networkBlockLine(block.time, block.tool, block.attributed, block.domain, block.scheme, block.port, block.action, block.matched, block.ruleIndex, block.reason));
3022
+ return {
3023
+ kind: "success",
3024
+ text: lines.join("\n")
3025
+ };
3026
+ }
1696
3027
  /**
1697
3028
  * Validate deployment-level file references at mount — the earliest
1698
3029
  * resolvable point. An absolute `rulesFile` or a configured
@@ -1918,32 +3249,52 @@ var PermissionRulesRuntime = class {
1918
3249
  * @param ctx - the host context.
1919
3250
  * @param config - raw plugin config.
1920
3251
  */
1921
- function apply(ctx, config) {
1922
- const runtime = new PermissionRulesRuntime(ctx, resolveConfig(config));
3252
+ async function apply(ctx, config) {
3253
+ const resolved = resolveConfig(config);
3254
+ const runtime = new PermissionRulesRuntime(ctx, resolved);
1923
3255
  runtime.validateDeploymentFiles();
1924
3256
  ctx.provide("permissionRulesRuntime", runtime);
1925
3257
  ctx.on("tools/pre-execute", (exec, next) => runtime.preExecute(exec, next));
3258
+ ctx.on("tools/post-execute", (exec, _result, next) => {
3259
+ runtime.unmarkShell(exec);
3260
+ return next();
3261
+ });
3262
+ attachSettingsSection(ctx, runtime, config);
3263
+ if (resolved.network.enabled) await runtime.attachNetworkProxy();
3264
+ ctx.inject(["systemPrompt"], (scope) => {
3265
+ const systemPrompt = scope.get("systemPrompt");
3266
+ if (systemPrompt?.context === void 0) return;
3267
+ systemPrompt.context({
3268
+ name: "network:policy",
3269
+ order: 115,
3270
+ text: "Network policy (permission-rules): shell commands reach the network only through a local policy proxy, and web tools are gated the same way — every target is allowed or blocked per the active rules and sandbox mode, and a blocked connection fails with a [network: …] message. Follow the denial messages and do not attempt to bypass the proxy."
3271
+ });
3272
+ });
3273
+ await ctx.plugin(PermissionRulesRemoteService, { runtime });
1926
3274
  ctx.commands.register({
1927
3275
  name: "rules",
1928
- description: "list, reload, audit, or dry-test the active permission rules for this workspace",
1929
- input: { hint: "[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]" },
3276
+ description: "list, reload, audit, dry-test, or inspect the network policy of the active permission rules for this workspace",
3277
+ input: { hint: "[list | reload | network | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]" },
1930
3278
  handler: (invocation) => runtime.command(invocation)
1931
3279
  });
1932
3280
  }
1933
3281
  /**
1934
3282
  * Whether a `@deepseek-ai/dsh-session` version line predates the
1935
- * `ignorable` envelope-marker surface: the `0.1.0-rc.6` line and earlier
1936
- * silently drop the marker from `Session.append` options, so audit events
1937
- * written by those builds land unmarked and break resume on stricter
1938
- * hosts. Non-matching (post-rc.6, stable, or unresolvable) versions are
1939
- * treated as possibly-marker-aware and verified by the append probe.
3283
+ * `ignorable` envelope-marker surface: every released rc line through
3284
+ * `0.1.0-rc.7` silently drops the marker from `Session.append` options
3285
+ * (the stamping fix exists on harness master only — no release carries it
3286
+ * yet), so audit events written by those builds land unmarked and break
3287
+ * resume on stricter hosts. Extend the bound when a new rc line ships that
3288
+ * still drops the marker. Non-matching (later rc, stable, or unresolvable)
3289
+ * versions are treated as possibly-marker-aware and verified by the
3290
+ * append probe.
1940
3291
  * @param version - the installed peer version string.
1941
- * @returns true for the known-unmarked rc.1–rc.6 lines.
3292
+ * @returns true for the known-unmarked rc.1–rc.7 lines.
1942
3293
  */
1943
3294
  function isUnmarkedHostVersion(version) {
1944
3295
  const match = /^0\.1\.0-rc\.(\d+)$/.exec(version.trim());
1945
3296
  if (match === null) return false;
1946
- return Number(match[1]) <= 6;
3297
+ return Number(match[1]) <= 7;
1947
3298
  }
1948
3299
  /**
1949
3300
  * Identity candidates for the `agents` match dimension, derived from the
@@ -2020,4 +3371,4 @@ function nextToken(text) {
2020
3371
  const name = "permission-rules";
2021
3372
  const inject = ["commands", "tools"];
2022
3373
  //#endregion
2023
- export { Config, GlobError, PATH_CANDIDATE_KEYS, PLATFORMS, PermissionRulesRuntime, RULE_ACTIONS, RuleError, apply, compileGlob, compilePatternRegex, compileRules, compileRulesChain, describeRule, extractPathCandidates, findUnreachableRules, inject, isMarkedAuditEvent, isUnmarkedHostVersion, matchRules, name, normalizeWorkspacePath, parseRulesDocument, resolveConfig };
3374
+ 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 };