dsh-permission-rules 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.es.md +22 -0
- package/README.hi.md +22 -0
- package/README.md +22 -0
- package/README.pt.md +22 -0
- package/README.zh.md +22 -0
- package/lib/client.js +4696 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1520 -172
- package/lib/typert.host.js +4305 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +9 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +181 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/section.d.ts +35 -0
- package/lib/types/client/section.d.ts.map +1 -0
- package/lib/types/config.d.ts +52 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/events.d.ts +45 -0
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/network.d.ts +90 -0
- package/lib/types/network.d.ts.map +1 -0
- package/lib/types/prose.d.ts +15 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/proxy.d.ts +128 -0
- package/lib/types/proxy.d.ts.map +1 -0
- package/lib/types/remote-service.d.ts +45 -0
- package/lib/types/remote-service.d.ts.map +1 -0
- package/lib/types/rules.d.ts +131 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +141 -12
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/settings.d.ts +28 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +166 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +421 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/package.json +40 -6
- package/src/client/index.ts +111 -0
- package/src/client/locales.ts +94 -0
- package/src/client/remote.ts +41 -0
- package/src/client/section.ts +201 -0
- package/src/config.ts +111 -0
- package/src/events.ts +46 -0
- package/src/index.ts +2 -0
- package/src/network.ts +177 -0
- package/src/prose.ts +85 -10
- package/src/proxy.ts +327 -0
- package/src/remote-service.ts +109 -0
- package/src/rules.ts +424 -5
- package/src/runtime.ts +454 -46
- package/src/settings.ts +74 -0
- package/src/typert.host.ts +25 -0
- package/src/wire.ts +239 -0
- package/lib/types/config.js +0 -99
- package/lib/types/config.js.map +0 -1
- package/lib/types/events.js +0 -44
- package/lib/types/events.js.map +0 -1
- package/lib/types/glob.js +0 -307
- package/lib/types/glob.js.map +0 -1
- package/lib/types/index.js +0 -27
- package/lib/types/index.js.map +0 -1
- package/lib/types/prose.js +0 -173
- package/lib/types/prose.js.map +0 -1
- package/lib/types/rules.js +0 -610
- package/lib/types/rules.js.map +0 -1
- package/lib/types/runtime.js +0 -818
- 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
/**
|
|
915
|
-
function
|
|
916
|
-
if (
|
|
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.
|
|
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:
|
|
1191
|
-
maxRules:
|
|
1192
|
-
maxGlobStars:
|
|
1193
|
-
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.
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
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
|
|
1399
|
-
const
|
|
1400
|
-
this.
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
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
|
|
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
|
-
|
|
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,15 +3249,32 @@ 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
|
|
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,
|
|
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
|
}
|
|
@@ -2020,4 +3368,4 @@ function nextToken(text) {
|
|
|
2020
3368
|
const name = "permission-rules";
|
|
2021
3369
|
const inject = ["commands", "tools"];
|
|
2022
3370
|
//#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 };
|
|
3371
|
+
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 };
|