dsh-permission-rules 0.6.21 → 0.7.1
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 +27 -0
- package/{README.es.md → README-es.md} +8 -3
- package/{README.hi.md → README-hi.md} +8 -3
- package/{README.pt.md → README-pt.md} +8 -3
- package/{README.zh.md → README-zh.md} +8 -3
- package/README.md +8 -3
- package/docs/rules-format.en.md +2 -1
- package/docs/rules-format.md +2 -1
- package/lib/client.js +6924 -37
- package/lib/client.js.map +1 -1
- package/lib/index.js +671 -28
- package/lib/typert.host.js +74 -3
- package/lib/types/allow-host.d.ts +151 -0
- package/lib/types/allow-host.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote.d.ts +54 -1
- package/lib/types/client/remote.d.ts.map +1 -1
- package/lib/types/client/section.d.ts +3 -1
- package/lib/types/client/section.d.ts.map +1 -1
- package/lib/types/config.d.ts +22 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/prose.d.ts +1 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/proxy.d.ts +73 -0
- package/lib/types/proxy.d.ts.map +1 -1
- package/lib/types/remote-service.d.ts +9 -1
- package/lib/types/remote-service.d.ts.map +1 -1
- package/lib/types/rules.d.ts +11 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +76 -11
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/typert.host.d.ts +50 -0
- package/lib/types/typert.host.d.ts.map +1 -1
- package/lib/types/wire.d.ts +191 -0
- package/lib/types/wire.d.ts.map +1 -1
- package/package.json +23 -23
- package/src/allow-host.ts +307 -0
- package/src/client/index.ts +7 -1
- package/src/client/locales.ts +36 -0
- package/src/client/remote.ts +4 -1
- package/src/client/section.ts +83 -10
- package/src/config.ts +56 -0
- package/src/prose.ts +6 -0
- package/src/proxy.ts +202 -16
- package/src/remote-service.ts +33 -1
- package/src/rules.ts +11 -2
- package/src/runtime.ts +243 -9
- package/src/wire.ts +119 -0
package/lib/index.js
CHANGED
|
@@ -4,11 +4,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
5
|
import chokidar from "chokidar";
|
|
6
6
|
import z from "@deepseek-ai/schemastery";
|
|
7
|
-
import { parse } from "yaml";
|
|
7
|
+
import { isMap, isSeq, parse, parseDocument } from "yaml";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { connect, isIP } from "node:net";
|
|
9
10
|
import { createServer, request } from "node:http";
|
|
10
11
|
import { request as request$1 } from "node:https";
|
|
11
|
-
import { connect } from "node:net";
|
|
12
12
|
import { lookup } from "node:dns/promises";
|
|
13
13
|
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
14
14
|
//#region src/glob.ts
|
|
@@ -1223,7 +1223,16 @@ function tryParseHttpUrl(text) {
|
|
|
1223
1223
|
ips: literalIpOf(host)
|
|
1224
1224
|
};
|
|
1225
1225
|
}
|
|
1226
|
-
/**
|
|
1226
|
+
/**
|
|
1227
|
+
* Lowercase, strip IPv6 brackets, strip one trailing dot.
|
|
1228
|
+
*
|
|
1229
|
+
* Exported because the settings-page allow action must normalize a blocked
|
|
1230
|
+
* host EXACTLY the way target parsing does, or a rule generated from a block
|
|
1231
|
+
* record could name a different host than the connection that was blocked
|
|
1232
|
+
* (`Example.COM.` and `example.com` are the same target).
|
|
1233
|
+
* @param host - a host name or IP literal as it arrived on the wire.
|
|
1234
|
+
* @returns the canonical spelling.
|
|
1235
|
+
*/
|
|
1227
1236
|
function normalizeHost(host) {
|
|
1228
1237
|
let out = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
1229
1238
|
while (out.endsWith(".")) out = out.slice(0, -1);
|
|
@@ -1669,7 +1678,9 @@ const Config = z.object({
|
|
|
1669
1678
|
proxyMaxRecent: z.number().default(100),
|
|
1670
1679
|
loopback: z.union(["allow", "policy"]).default("allow"),
|
|
1671
1680
|
injectEnv: z.boolean().default(true),
|
|
1672
|
-
noProxy: z.union(["clear", "preserve"]).default("clear")
|
|
1681
|
+
noProxy: z.union(["clear", "preserve"]).default("clear"),
|
|
1682
|
+
upstreamProxy: z.string().default("off"),
|
|
1683
|
+
allowHostAction: z.boolean().default(true)
|
|
1673
1684
|
}),
|
|
1674
1685
|
builtin: z.object({
|
|
1675
1686
|
enabled: z.boolean().default(true),
|
|
@@ -1758,12 +1769,15 @@ function resolveNetworkConfig(raw) {
|
|
|
1758
1769
|
assertEnum("network.noProxy", noProxy, ["clear", "preserve"]);
|
|
1759
1770
|
assertBoolean("network.enabled", raw?.enabled ?? true);
|
|
1760
1771
|
assertBoolean("network.injectEnv", raw?.injectEnv ?? true);
|
|
1772
|
+
assertBoolean("network.allowHostAction", raw?.allowHostAction ?? true);
|
|
1761
1773
|
const proxyBind = raw?.proxyBind ?? "127.0.0.1";
|
|
1762
1774
|
if (typeof proxyBind !== "string" || proxyBind.trim().length === 0) throw new TypeError(`network.proxyBind must be a non-empty string, got ${typeof proxyBind}`);
|
|
1763
1775
|
const proxyPort = raw?.proxyPort ?? 0;
|
|
1764
1776
|
if (!Number.isSafeInteger(proxyPort) || proxyPort < 0 || proxyPort > 65535) throw new TypeError(`network.proxyPort must be an integer 0-65535, got ${String(raw?.proxyPort)}`);
|
|
1765
1777
|
const proxyMaxRecent = raw?.proxyMaxRecent ?? 100;
|
|
1766
1778
|
if (!Number.isSafeInteger(proxyMaxRecent) || proxyMaxRecent <= 0) throw new TypeError(`network.proxyMaxRecent must be a positive safe integer, got ${String(raw?.proxyMaxRecent)}`);
|
|
1779
|
+
const upstreamProxy = raw?.upstreamProxy ?? "off";
|
|
1780
|
+
assertUpstreamProxy(upstreamProxy);
|
|
1767
1781
|
return {
|
|
1768
1782
|
enabled: raw?.enabled ?? true,
|
|
1769
1783
|
mode,
|
|
@@ -1774,9 +1788,33 @@ function resolveNetworkConfig(raw) {
|
|
|
1774
1788
|
proxyMaxRecent,
|
|
1775
1789
|
loopback,
|
|
1776
1790
|
injectEnv: raw?.injectEnv ?? true,
|
|
1777
|
-
noProxy
|
|
1791
|
+
noProxy,
|
|
1792
|
+
upstreamProxy,
|
|
1793
|
+
allowHostAction: raw?.allowHostAction ?? true
|
|
1778
1794
|
};
|
|
1779
1795
|
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Throw unless `value` is `off`, `inherit`, or a usable http(s) proxy URL.
|
|
1798
|
+
*
|
|
1799
|
+
* Node has no SOCKS client, and the harness's own policy refuses a proxy URL it
|
|
1800
|
+
* cannot express for a scheme, so a SOCKS value is rejected at the mount with an
|
|
1801
|
+
* actionable message instead of failing at connect time. `''` is rejected too:
|
|
1802
|
+
* an empty string reads as "no upstream" but silently disables chaining.
|
|
1803
|
+
*/
|
|
1804
|
+
function assertUpstreamProxy(value) {
|
|
1805
|
+
const invalid = (detail) => /* @__PURE__ */ new TypeError(`network.upstreamProxy must be "off" | "inherit" | an http(s) proxy URL, got ${JSON.stringify(value)}${detail}`);
|
|
1806
|
+
if (value === "off" || value === "inherit") return;
|
|
1807
|
+
if (typeof value !== "string" || value.trim().length === 0) throw invalid("");
|
|
1808
|
+
let parsed;
|
|
1809
|
+
try {
|
|
1810
|
+
parsed = new URL(value);
|
|
1811
|
+
} catch {
|
|
1812
|
+
parsed = void 0;
|
|
1813
|
+
}
|
|
1814
|
+
if (parsed === void 0) throw invalid("");
|
|
1815
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw invalid(` (${parsed.protocol}// is not supported: Node has no SOCKS client, so give an http:// or https:// proxy)`);
|
|
1816
|
+
if (parsed.hostname.length === 0) throw invalid(" (it must name a host)");
|
|
1817
|
+
}
|
|
1780
1818
|
/** Throw unless `key` is one of `allowed` (TypeScript's closed enums are not runtime checks). */
|
|
1781
1819
|
function assertEnum(key, value, allowed) {
|
|
1782
1820
|
if (!allowed.includes(value)) throw new TypeError(`${key} must be one of ${allowed.map((item) => JSON.stringify(item)).join(" | ")}, got ${JSON.stringify(value)}`);
|
|
@@ -1786,6 +1824,210 @@ function assertBoolean(key, value) {
|
|
|
1786
1824
|
if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean, got ${typeof value}`);
|
|
1787
1825
|
}
|
|
1788
1826
|
//#endregion
|
|
1827
|
+
//#region src/allow-host.ts
|
|
1828
|
+
/**
|
|
1829
|
+
* The settings-page "allow this host" action, as pure pieces: normalizing
|
|
1830
|
+
* and validating a blocked host, inserting ONE minimal
|
|
1831
|
+
* `match.network.domains` allow rule at the HEAD of a rule document, choosing
|
|
1832
|
+
* which rule file the rule belongs in, and mapping the action's result onto
|
|
1833
|
+
* the notice the page renders.
|
|
1834
|
+
*
|
|
1835
|
+
* Three invariants hold here and are asserted by `test/allow-host.spec.ts`:
|
|
1836
|
+
*
|
|
1837
|
+
* 1. the rule is inserted at index 0 — rules are first-match-wins, so a rule
|
|
1838
|
+
* appended after an existing `deny` would be dead text;
|
|
1839
|
+
* 2. a document the yaml parser reports an error for, or whose `rules` is not
|
|
1840
|
+
* a list, is REFUSED (a {@link RuleError}) — the caller never overwrites a
|
|
1841
|
+
* file it could not read; and
|
|
1842
|
+
* 3. the insertion goes through the yaml Document API (`parseDocument` +
|
|
1843
|
+
* `createNode` + `items.unshift` + `String(doc)`), so comments and the
|
|
1844
|
+
* existing formatting of the untouched rules survive.
|
|
1845
|
+
*
|
|
1846
|
+
* Nothing here reads the filesystem or the process cwd: the runtime injects
|
|
1847
|
+
* `exists`, `processCwd`, and the source lists it already computed, which is
|
|
1848
|
+
* what makes the target choice testable without touching a real host.
|
|
1849
|
+
* @module dsh-permission-rules/allow-host
|
|
1850
|
+
*/
|
|
1851
|
+
/**
|
|
1852
|
+
* Why a generated rule may legitimately live in a rule file the parser would
|
|
1853
|
+
* reject: it is not one — every write goes back through the same
|
|
1854
|
+
* `parseRulesDocument` + `compileRules` gate as a hand edit.
|
|
1855
|
+
*/
|
|
1856
|
+
const RULE_REASON_SUFFIX = "(added from the settings page)";
|
|
1857
|
+
/**
|
|
1858
|
+
* A DNS-ish host: dot-separated labels of ASCII letters/digits/`-`/`_`, each
|
|
1859
|
+
* starting and ending with an alphanumeric. Deliberately narrow — the action
|
|
1860
|
+
* writes an EXACT host, so glob metacharacters (`*`), whitespace, `#`, `:`,
|
|
1861
|
+
* `[`, quotes and anything else that could make the generated scalar mean
|
|
1862
|
+
* something other than "this one host" are refused instead of escaped.
|
|
1863
|
+
*/
|
|
1864
|
+
const HOSTNAME_SHAPE = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)*$/;
|
|
1865
|
+
/** Longest a host name (or an IP literal) may be; also bounds the generated rule. */
|
|
1866
|
+
const MAX_HOST_LENGTH = 253;
|
|
1867
|
+
/**
|
|
1868
|
+
* Canonical spelling of a host for the allow action, matching target parsing
|
|
1869
|
+
* exactly (see `normalizeHost`): lowercase, IPv6 brackets stripped, one
|
|
1870
|
+
* trailing dot stripped.
|
|
1871
|
+
* @param host - the host as the block record reported it.
|
|
1872
|
+
* @returns the canonical spelling.
|
|
1873
|
+
*/
|
|
1874
|
+
function normalizeAllowHost(host) {
|
|
1875
|
+
return normalizeHost(host);
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Whether a normalized host may be written into a rule: a host name or an IP
|
|
1879
|
+
* literal, nothing else. This is the syntactic half of the action's safety —
|
|
1880
|
+
* a newline, a `rules:` fragment, a glob, a URL, or a `host:port` authority is
|
|
1881
|
+
* refused here rather than relying on the YAML emitter to quote it. The IP
|
|
1882
|
+
* check is `node:net`'s `isIP`, so a `host:port` string (which merely contains
|
|
1883
|
+
* a colon) is not mistaken for an IPv6 literal.
|
|
1884
|
+
* @param host - the normalized host.
|
|
1885
|
+
* @returns true when the host may be allowed.
|
|
1886
|
+
*/
|
|
1887
|
+
function isWritableHost(host) {
|
|
1888
|
+
if (host.length === 0 || host.length > MAX_HOST_LENGTH) return false;
|
|
1889
|
+
if (isIP(host) !== 0) return true;
|
|
1890
|
+
return HOSTNAME_SHAPE.test(host);
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* The `reason` carried by every generated rule. English on purpose: rule
|
|
1894
|
+
* `reason`s are never translated (they are quoted verbatim in denial messages
|
|
1895
|
+
* and in the session audit), so a localized reason would make one rule read
|
|
1896
|
+
* differently per UI language.
|
|
1897
|
+
* @param host - the allowed host.
|
|
1898
|
+
* @returns the reason line.
|
|
1899
|
+
*/
|
|
1900
|
+
function allowHostReason(host) {
|
|
1901
|
+
return `Allow ${host} ${RULE_REASON_SUFFIX}`;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Insert the minimal allow rule for `host` at the HEAD of a rule document.
|
|
1905
|
+
*
|
|
1906
|
+
* `inserted: false` (with the input text returned unchanged) means the first
|
|
1907
|
+
* rule of the file is ALREADY this exact allow — first-match semantics make a
|
|
1908
|
+
* second copy do nothing, so the caller must not rewrite the file (repeated
|
|
1909
|
+
* clicks would otherwise pile up duplicate lines in the user's git diff).
|
|
1910
|
+
*
|
|
1911
|
+
* @param text - the current file text (empty for a file to be created).
|
|
1912
|
+
* @param host - the normalized, validated host.
|
|
1913
|
+
* @returns the text to write plus whether anything was added.
|
|
1914
|
+
* @throws RuleError when the document cannot be parsed or has no `rules` list.
|
|
1915
|
+
*/
|
|
1916
|
+
function insertAllowRule(text, host) {
|
|
1917
|
+
const normalized = normalizeAllowHost(host);
|
|
1918
|
+
const doc = parseDocument(text);
|
|
1919
|
+
if (doc.errors.length > 0) throw new RuleError(`cannot edit the rule file: ${doc.errors[0]?.message ?? "invalid YAML"}`);
|
|
1920
|
+
const rule = {
|
|
1921
|
+
match: { network: { domains: [normalized] } },
|
|
1922
|
+
action: "allow",
|
|
1923
|
+
reason: allowHostReason(normalized)
|
|
1924
|
+
};
|
|
1925
|
+
if (doc.contents === null) {
|
|
1926
|
+
doc.set("rules", doc.createNode([rule]));
|
|
1927
|
+
return {
|
|
1928
|
+
text: String(doc),
|
|
1929
|
+
inserted: true
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
if (!isMap(doc.contents)) throw new RuleError("cannot edit the rule file: the document root must be a mapping with a \"rules\" list");
|
|
1933
|
+
if (!doc.has("rules")) throw new RuleError("cannot edit the rule file: the document has no \"rules\" list");
|
|
1934
|
+
const rules = doc.get("rules");
|
|
1935
|
+
if (!isSeq(rules)) throw new RuleError("cannot edit the rule file: \"rules\" must be a list");
|
|
1936
|
+
if (headAllowHost(rules) === normalized) return {
|
|
1937
|
+
text,
|
|
1938
|
+
inserted: false
|
|
1939
|
+
};
|
|
1940
|
+
rules.items.unshift(doc.createNode(rule));
|
|
1941
|
+
return {
|
|
1942
|
+
text: String(doc),
|
|
1943
|
+
inserted: true
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* The host of the first rule, when that rule is exactly the allow rule this
|
|
1948
|
+
* action generates: `action: allow`, enabled, and a `match` whose only
|
|
1949
|
+
* dimension is a single `network.domains` entry. Anything else — a different
|
|
1950
|
+
* action, a disabled rule, or a rule narrowed by ports/schemes/tools/agents —
|
|
1951
|
+
* returns undefined, so an existing narrower rule can never make the action
|
|
1952
|
+
* skip the write that would actually unblock the connection.
|
|
1953
|
+
* @param rules - the parsed `rules` sequence.
|
|
1954
|
+
* @returns the normalized head host, or undefined.
|
|
1955
|
+
*/
|
|
1956
|
+
function headAllowHost(rules) {
|
|
1957
|
+
const first = rules.items[0];
|
|
1958
|
+
if (!isMap(first)) return void 0;
|
|
1959
|
+
const record = first.toJSON();
|
|
1960
|
+
if (typeof record !== "object" || record === null || Array.isArray(record)) return void 0;
|
|
1961
|
+
const rule = record;
|
|
1962
|
+
if (rule["action"] !== "allow" || rule["enabled"] === false) return void 0;
|
|
1963
|
+
const match = rule["match"];
|
|
1964
|
+
if (typeof match !== "object" || match === null || Array.isArray(match)) return void 0;
|
|
1965
|
+
const dimensions = Object.keys(match);
|
|
1966
|
+
if (dimensions.length !== 1 || dimensions[0] !== "network") return void 0;
|
|
1967
|
+
const network = match["network"];
|
|
1968
|
+
if (typeof network !== "object" || network === null || Array.isArray(network)) return void 0;
|
|
1969
|
+
const scopes = Object.keys(network);
|
|
1970
|
+
if (scopes.length !== 1 || scopes[0] !== "domains") return void 0;
|
|
1971
|
+
const domains = network["domains"];
|
|
1972
|
+
if (!Array.isArray(domains) || domains.length !== 1) return void 0;
|
|
1973
|
+
const only = domains[0];
|
|
1974
|
+
return typeof only === "string" ? normalizeAllowHost(only) : void 0;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* The one rule file this action may write for a request: the nearest file of
|
|
1978
|
+
* the chain that will actually judge the connection.
|
|
1979
|
+
*
|
|
1980
|
+
* With a workspace cwd the target is that workspace's project file (the
|
|
1981
|
+
* absolute `rulesFile` when one is configured), and the cwd must be a
|
|
1982
|
+
* workspace whose chain is loaded — an unknown cwd is refused instead of being
|
|
1983
|
+
* turned into a path.
|
|
1984
|
+
*
|
|
1985
|
+
* Without one, the order mirrors `resolveUserSources(process.cwd())` exactly,
|
|
1986
|
+
* so the file written is the file the host chain reads: the absolute
|
|
1987
|
+
* `rulesFile`, else `<processCwd>/<rulesFile>` when it already exists, else
|
|
1988
|
+
* the configured fallback, else `<processCwd>/<rulesFile>` (created — the
|
|
1989
|
+
* host chain picks it up on the next load).
|
|
1990
|
+
*
|
|
1991
|
+
* The result must be a known rule source and must never be the built-in
|
|
1992
|
+
* baseline.
|
|
1993
|
+
*
|
|
1994
|
+
* @param input - the injected config, cwd, and source state.
|
|
1995
|
+
* @returns the absolute path to write.
|
|
1996
|
+
* @throws RuleError when the request names an unknown workspace or resolves to
|
|
1997
|
+
* a path outside the writable set.
|
|
1998
|
+
*/
|
|
1999
|
+
function resolveAllowHostTarget(input) {
|
|
2000
|
+
const target = input.cwd === null ? hostTarget(input) : workspaceTarget(input, input.cwd);
|
|
2001
|
+
if (input.builtinPath !== void 0 && samePath(target, input.builtinPath)) throw new RuleError(`refusing to write ${target}: the built-in ruleset is read-only`);
|
|
2002
|
+
if (!input.knownSources.some((source) => samePath(source, target))) throw new RuleError(`refusing to write ${target}: not a known rule source`);
|
|
2003
|
+
return target;
|
|
2004
|
+
}
|
|
2005
|
+
/** The project file of one workspace (the absolute `rulesFile` when one is configured). */
|
|
2006
|
+
function projectFile(rulesFile, cwd) {
|
|
2007
|
+
return isAbsolute(rulesFile) ? rulesFile : join(cwd, rulesFile);
|
|
2008
|
+
}
|
|
2009
|
+
/** The target for a request that named a workspace. */
|
|
2010
|
+
function workspaceTarget(input, cwd) {
|
|
2011
|
+
const owner = input.loadedCwds.find((loaded) => samePath(loaded, cwd));
|
|
2012
|
+
if (owner === void 0) throw new RuleError(`refusing to allow for ${JSON.stringify(cwd)}: not a workspace whose rules are loaded`);
|
|
2013
|
+
return projectFile(input.rulesFile, owner);
|
|
2014
|
+
}
|
|
2015
|
+
/** The target for a host-level (session-less) request — the same order the host chain resolves in. */
|
|
2016
|
+
function hostTarget(input) {
|
|
2017
|
+
if (isAbsolute(input.rulesFile)) return input.rulesFile;
|
|
2018
|
+
const project = join(input.processCwd, input.rulesFile);
|
|
2019
|
+
if (input.exists(project)) return project;
|
|
2020
|
+
const fallback = input.fallbackPath;
|
|
2021
|
+
if (fallback === void 0) return project;
|
|
2022
|
+
return isAbsolute(fallback) ? fallback : resolve(fallback);
|
|
2023
|
+
}
|
|
2024
|
+
/** Path equality as the per-workspace cache key treats it: resolved, and case-folded on Windows. */
|
|
2025
|
+
function samePath(a, b) {
|
|
2026
|
+
const left = resolve(a);
|
|
2027
|
+
const right = resolve(b);
|
|
2028
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
2029
|
+
}
|
|
2030
|
+
//#endregion
|
|
1789
2031
|
//#region src/prose.ts
|
|
1790
2032
|
/** Truncate long reasons/descriptions in one-line displays. */
|
|
1791
2033
|
function short(text, limit = 120) {
|
|
@@ -1820,6 +2062,7 @@ const EN = {
|
|
|
1820
2062
|
networkDisabled: "Network policy disabled (network.enabled: false): no proxy, no web-tool mode defaults.",
|
|
1821
2063
|
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)"}.`,
|
|
1822
2064
|
networkCounters: (denied, askBlocked) => `Blocks: ${denied} denied, ${askBlocked} ask-blocked.`,
|
|
2065
|
+
networkUpstream: (mode, http, https, active, chained) => `Upstream: ${mode}${http === null ? "" : ` http ${http}`}${https === null ? "" : ` https ${https}`}${active ? "" : " (inactive)"}, ${chained} chained.`,
|
|
1823
2066
|
noNetworkBlocks: "No network blocks recorded yet.",
|
|
1824
2067
|
networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
|
|
1825
2068
|
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)}`}`;
|
|
@@ -1854,6 +2097,7 @@ const ZH = {
|
|
|
1854
2097
|
networkDisabled: "网络策略已停用(network.enabled: false):无代理、无 web 工具模式默认裁决。",
|
|
1855
2098
|
networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `网络策略:模式 ${mode}${sandboxMode === void 0 ? "" : `(沙箱预设 ${sandboxMode})`}${configuredMode === "auto" ? "" : `(显式配置 ${configuredMode})`};代理${proxyActive ? `运行于 127.0.0.1:${proxyPort}` : "未激活(绑定失败——shell 网络策略未生效)"}。`,
|
|
1856
2099
|
networkCounters: (denied, askBlocked) => `拦截:拒绝 ${denied} 次,待审批阻断 ${askBlocked} 次。`,
|
|
2100
|
+
networkUpstream: (mode, http, https, active, chained) => `上游:${mode}${http === null ? "" : ` http ${http}`}${https === null ? "" : ` https ${https}`}${active ? "" : "(未生效)"},已链式 ${chained} 条。`,
|
|
1857
2101
|
noNetworkBlocks: "尚未记录任何网络拦截。",
|
|
1858
2102
|
networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
|
|
1859
2103
|
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)}`}`;
|
|
@@ -1888,6 +2132,7 @@ const ES = {
|
|
|
1888
2132
|
networkDisabled: "Política de red desactivada (network.enabled: false): sin proxy, sin valores predeterminados de modo para herramientas web.",
|
|
1889
2133
|
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)"}.`,
|
|
1890
2134
|
networkCounters: (denied, askBlocked) => `Bloqueos: ${denied} denegados, ${askBlocked} bloqueados por aprobación.`,
|
|
2135
|
+
networkUpstream: (mode, http, https, active, chained) => `Upstream: ${mode}${http === null ? "" : ` http ${http}`}${https === null ? "" : ` https ${https}`}${active ? "" : " (inactivo)"}, ${chained} encadenadas.`,
|
|
1891
2136
|
noNetworkBlocks: "Aún no se han registrado bloqueos de red.",
|
|
1892
2137
|
networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
|
|
1893
2138
|
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)}`}`;
|
|
@@ -1922,6 +2167,7 @@ const PT = {
|
|
|
1922
2167
|
networkDisabled: "Política de rede desativada (network.enabled: false): sem proxy, sem padrões de modo para ferramentas web.",
|
|
1923
2168
|
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)"}.`,
|
|
1924
2169
|
networkCounters: (denied, askBlocked) => `Bloqueios: ${denied} negados, ${askBlocked} bloqueados por aprovação.`,
|
|
2170
|
+
networkUpstream: (mode, http, https, active, chained) => `Upstream: ${mode}${http === null ? "" : ` http ${http}`}${https === null ? "" : ` https ${https}`}${active ? "" : " (inativo)"}, ${chained} encadeadas.`,
|
|
1925
2171
|
noNetworkBlocks: "Nenhum bloqueio de rede registrado ainda.",
|
|
1926
2172
|
networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
|
|
1927
2173
|
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)}`}`;
|
|
@@ -1956,6 +2202,7 @@ const HI = {
|
|
|
1956
2202
|
networkDisabled: "नेटवर्क नीति अक्षम (network.enabled: false): कोई प्रॉक्सी नहीं, कोई वेब-टूल मोड डिफ़ॉल्ट नहीं।",
|
|
1957
2203
|
networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `नेटवर्क नीति: मोड ${mode}${sandboxMode === void 0 ? "" : ` (सैंडबॉक्स प्रीसेट ${sandboxMode})`}${configuredMode === "auto" ? "" : ` (कॉन्फ़िगर किया ${configuredMode})`}; प्रॉक्सी ${proxyActive ? `सक्रिय 127.0.0.1:${proxyPort} पर` : "निष्क्रिय (बाइंड विफल — शेल नेटवर्क नीति लागू नहीं)"}।`,
|
|
1958
2204
|
networkCounters: (denied, askBlocked) => `ब्लॉक: ${denied} अस्वीकृत, ${askBlocked} अनुमोदन-ब्लॉक।`,
|
|
2205
|
+
networkUpstream: (mode, http, https, active, chained) => `Upstream: ${mode}${http === null ? "" : ` http ${http}`}${https === null ? "" : ` https ${https}`}${active ? "" : " (निष्क्रिय)"}, ${chained} श्रृंखलित।`,
|
|
1959
2206
|
noNetworkBlocks: "अभी तक कोई नेटवर्क ब्लॉक दर्ज नहीं हुआ।",
|
|
1960
2207
|
networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
|
|
1961
2208
|
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)}`}`;
|
|
@@ -2096,6 +2343,65 @@ const PROXY_ENV_NAMES = [
|
|
|
2096
2343
|
];
|
|
2097
2344
|
/** NO_PROXY names cleared (or preserved) by the injector. */
|
|
2098
2345
|
const NO_PROXY_ENV_NAMES = ["NO_PROXY", "no_proxy"];
|
|
2346
|
+
/** How long a chained CONNECT waits for the upstream to answer before it gives up. */
|
|
2347
|
+
const UPSTREAM_CONNECT_TIMEOUT_MS = 1e4;
|
|
2348
|
+
/**
|
|
2349
|
+
* Resolve ambient upstream candidates the way the harness's own proxy policy
|
|
2350
|
+
* does: the scheme-specific name (lowercase first, blank treated as unset),
|
|
2351
|
+
* then `ALL_PROXY`, then — for https only — the http name. A value that is not
|
|
2352
|
+
* a parseable `http(s)://` URL is dropped here; the runtime reports those once.
|
|
2353
|
+
* @param lookup - reads one environment name (the launch snapshot, or `process.env`).
|
|
2354
|
+
* @returns the usable candidates, each absent when it is unusable.
|
|
2355
|
+
*/
|
|
2356
|
+
function readAmbientProxy(lookup) {
|
|
2357
|
+
const pick = (names) => {
|
|
2358
|
+
for (const name of names) {
|
|
2359
|
+
const value = lookup(name);
|
|
2360
|
+
if (value !== void 0 && value.trim().length > 0) return value.trim();
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
const usable = (value) => {
|
|
2364
|
+
if (value === void 0) return void 0;
|
|
2365
|
+
try {
|
|
2366
|
+
const parsed = new URL(value);
|
|
2367
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? value : void 0;
|
|
2368
|
+
} catch {
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
const all = usable(pick(["ALL_PROXY", "all_proxy"]));
|
|
2373
|
+
const http = usable(pick(["http_proxy", "HTTP_PROXY"])) ?? all;
|
|
2374
|
+
const https = usable(pick(["https_proxy", "HTTPS_PROXY"])) ?? all ?? http;
|
|
2375
|
+
return {
|
|
2376
|
+
...http === void 0 ? {} : { http },
|
|
2377
|
+
...https === void 0 ? {} : { https }
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* The loggable form of a proxy URL: the password is replaced, everything else
|
|
2382
|
+
* is kept verbatim. An upstream URL may carry credentials, and a proxy URL is
|
|
2383
|
+
* printed in warnings, `/rules network` and the settings snapshot, so it must
|
|
2384
|
+
* never be emitted raw.
|
|
2385
|
+
* @param value - the configured upstream URL.
|
|
2386
|
+
* @returns the same URL with its password masked, or the input when unparseable.
|
|
2387
|
+
*/
|
|
2388
|
+
function redactProxyUrl(value) {
|
|
2389
|
+
try {
|
|
2390
|
+
const parsed = new URL(value);
|
|
2391
|
+
if (parsed.username === "" && parsed.password === "") return value;
|
|
2392
|
+
const user = parsed.username;
|
|
2393
|
+
const auth = parsed.password === "" ? `${user}@` : `${user}:***@`;
|
|
2394
|
+
return `${parsed.protocol}//${auth}${parsed.host}`;
|
|
2395
|
+
} catch {
|
|
2396
|
+
return value;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
/** The `Proxy-Authorization` value for an upstream URL carrying userinfo. */
|
|
2400
|
+
function proxyAuthorization(url) {
|
|
2401
|
+
if (url.username === "" && url.password === "") return void 0;
|
|
2402
|
+
const credentials = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`;
|
|
2403
|
+
return `Basic ${Buffer.from(credentials).toString("base64")}`;
|
|
2404
|
+
}
|
|
2099
2405
|
/**
|
|
2100
2406
|
* The local HTTP/CONNECT policy proxy. Binds on demand (the plugin calls
|
|
2101
2407
|
* {@link start} inside an effect and {@link close} from its disposer);
|
|
@@ -2112,9 +2418,14 @@ var NetworkProxy = class {
|
|
|
2112
2418
|
askBlocked: 0
|
|
2113
2419
|
};
|
|
2114
2420
|
actualPort = 0;
|
|
2421
|
+
chained = 0;
|
|
2115
2422
|
constructor(options) {
|
|
2116
2423
|
this.options = options;
|
|
2117
2424
|
}
|
|
2425
|
+
/** Connections dispatched through the configured upstream since mount. */
|
|
2426
|
+
chainedConnections() {
|
|
2427
|
+
return this.chained;
|
|
2428
|
+
}
|
|
2118
2429
|
/** The bound port (valid after {@link start} resolves). */
|
|
2119
2430
|
get port() {
|
|
2120
2431
|
return this.actualPort;
|
|
@@ -2166,6 +2477,75 @@ var NetworkProxy = class {
|
|
|
2166
2477
|
server.close(() => resolve());
|
|
2167
2478
|
});
|
|
2168
2479
|
}
|
|
2480
|
+
/**
|
|
2481
|
+
* The upstream one ALLOWED connection may be chained through, or undefined to
|
|
2482
|
+
* dial directly. Three vetoes, in order:
|
|
2483
|
+
*
|
|
2484
|
+
* 1. a loopback target never chains — a proxy outside this host cannot route
|
|
2485
|
+
* its loopback, and the harness's own policy bypasses loopback for the same
|
|
2486
|
+
* reason;
|
|
2487
|
+
* 2. an `ips`-scoped decision never chains — chaining hands the hostname to
|
|
2488
|
+
* the upstream, so "the connection lands on an address the rules saw"
|
|
2489
|
+
* (the issue #21 invariant) would stop holding exactly where the rules
|
|
2490
|
+
* cared about the address;
|
|
2491
|
+
* 3. no usable upstream for the target's scheme.
|
|
2492
|
+
*
|
|
2493
|
+
* A blocked connection never reaches this method: it is called only on the
|
|
2494
|
+
* `allow` branch, after the decision is final.
|
|
2495
|
+
* @param target - the adjudicated target.
|
|
2496
|
+
* @param decision - the decision that allowed it.
|
|
2497
|
+
* @returns the upstream URL to chain through, or undefined.
|
|
2498
|
+
*/
|
|
2499
|
+
upstreamRouteFor(target, decision) {
|
|
2500
|
+
const ambient = this.options.upstream?.();
|
|
2501
|
+
if (ambient === void 0) return void 0;
|
|
2502
|
+
if (isLoopbackTarget(target)) return void 0;
|
|
2503
|
+
const ipsScope = decision.rule?.source.match.network?.ips;
|
|
2504
|
+
if (ipsScope !== void 0 && ipsScope.length > 0) return void 0;
|
|
2505
|
+
return (target.scheme ?? "http") === "https" ? ambient.https : ambient.http;
|
|
2506
|
+
}
|
|
2507
|
+
/**
|
|
2508
|
+
* Open a tunnel by asking the upstream for one, and hand back the socket it
|
|
2509
|
+
* established. The upstream's own hostname is resolved by the system: it is
|
|
2510
|
+
* operator configuration, not agent input, so it takes no part in rule
|
|
2511
|
+
* adjudication and is outside the adjudicated-address invariant.
|
|
2512
|
+
* @param upstream - the upstream proxy URL.
|
|
2513
|
+
* @param authority - the `host:port` to ask for (IPv6 already bracketed).
|
|
2514
|
+
* @returns the established tunnel socket.
|
|
2515
|
+
*/
|
|
2516
|
+
connectViaUpstream(upstream, authority) {
|
|
2517
|
+
return new Promise((resolve, reject) => {
|
|
2518
|
+
const via = new URL(upstream);
|
|
2519
|
+
const send = via.protocol === "https:" ? request$1 : request;
|
|
2520
|
+
const headers = { host: authority };
|
|
2521
|
+
const auth = proxyAuthorization(via);
|
|
2522
|
+
if (auth !== void 0) headers["proxy-authorization"] = auth;
|
|
2523
|
+
const req = send({
|
|
2524
|
+
host: via.hostname,
|
|
2525
|
+
port: via.port === "" ? via.protocol === "https:" ? 443 : 80 : Number(via.port),
|
|
2526
|
+
method: "CONNECT",
|
|
2527
|
+
path: authority,
|
|
2528
|
+
headers
|
|
2529
|
+
});
|
|
2530
|
+
req.setTimeout(UPSTREAM_CONNECT_TIMEOUT_MS, () => {
|
|
2531
|
+
req.destroy(/* @__PURE__ */ new Error("upstream CONNECT timed out"));
|
|
2532
|
+
});
|
|
2533
|
+
req.on("connect", (response, socket) => {
|
|
2534
|
+
const status = response.statusCode ?? 0;
|
|
2535
|
+
if (status >= 200 && status < 300) resolve(socket);
|
|
2536
|
+
else {
|
|
2537
|
+
socket.destroy();
|
|
2538
|
+
reject(/* @__PURE__ */ new Error(`upstream answered ${String(status)}`));
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
req.on("response", (response) => {
|
|
2542
|
+
response.resume();
|
|
2543
|
+
reject(/* @__PURE__ */ new Error(`upstream answered ${String(response.statusCode ?? 0)}`));
|
|
2544
|
+
});
|
|
2545
|
+
req.on("error", reject);
|
|
2546
|
+
req.end();
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2169
2549
|
/** Plain-HTTP proxying: absolute-form requests are adjudicated and forwarded (or blocked with a structured 403). */
|
|
2170
2550
|
async handleRequest(req, res) {
|
|
2171
2551
|
const target = parseUrlTarget(req.url ?? "");
|
|
@@ -2186,16 +2566,34 @@ var NetworkProxy = class {
|
|
|
2186
2566
|
return;
|
|
2187
2567
|
}
|
|
2188
2568
|
const upstream = new URL(req.url);
|
|
2189
|
-
const
|
|
2190
|
-
const pinned = pinnedAddresses(adjudicated);
|
|
2191
|
-
const proxyReq = send(upstream, {
|
|
2192
|
-
method: req.method,
|
|
2193
|
-
headers: req.headers,
|
|
2194
|
-
...pinned.length === 0 ? {} : { lookup: pinnedLookup(pinned) }
|
|
2195
|
-
}, (proxyRes) => {
|
|
2569
|
+
const onResponse = (proxyRes) => {
|
|
2196
2570
|
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
2197
2571
|
proxyRes.pipe(res);
|
|
2198
|
-
}
|
|
2572
|
+
};
|
|
2573
|
+
const chained = this.upstreamRouteFor(adjudicated, decision);
|
|
2574
|
+
let proxyReq;
|
|
2575
|
+
if (chained === void 0) {
|
|
2576
|
+
const pinned = pinnedAddresses(adjudicated);
|
|
2577
|
+
const options = {
|
|
2578
|
+
method: req.method,
|
|
2579
|
+
headers: req.headers,
|
|
2580
|
+
...pinned.length === 0 ? {} : { lookup: pinnedLookup(pinned) }
|
|
2581
|
+
};
|
|
2582
|
+
proxyReq = (upstream.protocol === "https:" ? request$1 : request)(upstream, options, onResponse);
|
|
2583
|
+
} else {
|
|
2584
|
+
const via = new URL(chained);
|
|
2585
|
+
const headers = { ...req.headers };
|
|
2586
|
+
const auth = proxyAuthorization(via);
|
|
2587
|
+
if (auth !== void 0) headers["proxy-authorization"] = auth;
|
|
2588
|
+
proxyReq = (via.protocol === "https:" ? request$1 : request)({
|
|
2589
|
+
host: via.hostname,
|
|
2590
|
+
port: via.port === "" ? via.protocol === "https:" ? 443 : 80 : Number(via.port),
|
|
2591
|
+
method: req.method,
|
|
2592
|
+
path: req.url,
|
|
2593
|
+
headers
|
|
2594
|
+
}, onResponse);
|
|
2595
|
+
this.chained += 1;
|
|
2596
|
+
}
|
|
2199
2597
|
proxyReq.on("error", (error) => {
|
|
2200
2598
|
if (!res.headersSent) {
|
|
2201
2599
|
res.writeHead(502, { "content-type": "text/plain" });
|
|
@@ -2258,10 +2656,12 @@ var NetworkProxy = class {
|
|
|
2258
2656
|
return;
|
|
2259
2657
|
}
|
|
2260
2658
|
let tunnel;
|
|
2659
|
+
const chained = this.upstreamRouteFor(adjudicated, decision);
|
|
2261
2660
|
try {
|
|
2262
|
-
tunnel = await this.connectUpstream(adjudicated, target.port ?? 443);
|
|
2661
|
+
tunnel = chained === void 0 ? await this.connectUpstream(adjudicated, target.port ?? 443) : await this.connectViaUpstream(chained, target.host.includes(":") ? `[${target.host}]:${String(target.port ?? 443)}` : `${target.host}:${String(target.port ?? 443)}`);
|
|
2662
|
+
if (chained !== void 0) this.chained += 1;
|
|
2263
2663
|
} catch (error) {
|
|
2264
|
-
this.options.logger.warn(`permission-rules: upstream connect failed for ${target.host}: ${String(error)}`);
|
|
2664
|
+
this.options.logger.warn(`permission-rules: upstream connect failed for ${target.host}: ${String(error)}${chained === void 0 ? "" : ` (via ${redactProxyUrl(chained)})`}`);
|
|
2265
2665
|
socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
2266
2666
|
return;
|
|
2267
2667
|
}
|
|
@@ -2306,6 +2706,7 @@ var NetworkProxy = class {
|
|
|
2306
2706
|
/** Record a block: counters, recent ring, and the runtime hook (logger + session audit). */
|
|
2307
2707
|
recordBlock(decision, target) {
|
|
2308
2708
|
const attribution = this.options.attribution?.();
|
|
2709
|
+
const cwd = attribution?.agent?.session.header.cwd;
|
|
2309
2710
|
const record = {
|
|
2310
2711
|
time: Date.now(),
|
|
2311
2712
|
tool: attribution?.tool ?? "subprocess",
|
|
@@ -2319,7 +2720,8 @@ var NetworkProxy = class {
|
|
|
2319
2720
|
matched: decision.matched,
|
|
2320
2721
|
source: decision.source ?? "",
|
|
2321
2722
|
...decision.ruleIndex !== void 0 ? { ruleIndex: decision.ruleIndex } : {},
|
|
2322
|
-
...decision.rule !== void 0 ? { reason: decision.rule.reason } : {}
|
|
2723
|
+
...decision.rule !== void 0 ? { reason: decision.rule.reason } : {},
|
|
2724
|
+
...cwd !== void 0 ? { cwd } : {}
|
|
2323
2725
|
};
|
|
2324
2726
|
if (record.action === "deny") this.stats.denied += 1;
|
|
2325
2727
|
else this.stats.askBlocked += 1;
|
|
@@ -2466,9 +2868,18 @@ var PermissionRulesRemoteService = class extends TypertRemoteService {
|
|
|
2466
2868
|
matched: block.matched,
|
|
2467
2869
|
source: block.source,
|
|
2468
2870
|
ruleIndex: block.ruleIndex ?? null,
|
|
2469
|
-
reason: block.reason ?? null
|
|
2871
|
+
reason: block.reason ?? null,
|
|
2872
|
+
cwd: block.cwd ?? null
|
|
2470
2873
|
})),
|
|
2471
|
-
sources
|
|
2874
|
+
sources,
|
|
2875
|
+
allowHostAction: snapshot.allowHostAction,
|
|
2876
|
+
upstream: {
|
|
2877
|
+
mode: snapshot.upstream.mode,
|
|
2878
|
+
http: snapshot.upstream.http,
|
|
2879
|
+
https: snapshot.upstream.https,
|
|
2880
|
+
active: snapshot.upstream.active,
|
|
2881
|
+
chained: snapshot.upstream.chained
|
|
2882
|
+
}
|
|
2472
2883
|
};
|
|
2473
2884
|
}
|
|
2474
2885
|
/** Read one known rule file for the editor. */
|
|
@@ -2505,6 +2916,28 @@ var PermissionRulesRemoteService = class extends TypertRemoteService {
|
|
|
2505
2916
|
};
|
|
2506
2917
|
}
|
|
2507
2918
|
}
|
|
2919
|
+
/**
|
|
2920
|
+
* Write one minimal allow rule for a blocked host into the nearest
|
|
2921
|
+
* effective rule file (the settings page's per-block action). The runtime
|
|
2922
|
+
* owns the whole decision — which file, index 0, the validation gate, the
|
|
2923
|
+
* reload — and never throws; the try/catch is the same containment
|
|
2924
|
+
* `reload()` uses so the page always receives a well-formed result.
|
|
2925
|
+
*/
|
|
2926
|
+
allowHost(request) {
|
|
2927
|
+
try {
|
|
2928
|
+
return this.config.runtime.allowHost(request);
|
|
2929
|
+
} catch (error) {
|
|
2930
|
+
return {
|
|
2931
|
+
ok: false,
|
|
2932
|
+
path: null,
|
|
2933
|
+
created: false,
|
|
2934
|
+
reloaded: 0,
|
|
2935
|
+
outcome: null,
|
|
2936
|
+
alreadyAllowed: false,
|
|
2937
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2508
2941
|
};
|
|
2509
2942
|
//#endregion
|
|
2510
2943
|
//#region src/settings.ts
|
|
@@ -2625,6 +3058,22 @@ const POLLING_WATCH_OPTIONS = {
|
|
|
2625
3058
|
* State and behavior. One instance per plugin mount; disposals are owned by
|
|
2626
3059
|
* the watcher/timer/proxy effects registered in {@link apply}.
|
|
2627
3060
|
*/
|
|
3061
|
+
/**
|
|
3062
|
+
* Whether a configured upstream addresses this proxy's own bind address and
|
|
3063
|
+
* port, which would make every chained connection recurse into this proxy.
|
|
3064
|
+
*/
|
|
3065
|
+
function isSelfUpstream(value, bind, port) {
|
|
3066
|
+
try {
|
|
3067
|
+
const parsed = new URL(value);
|
|
3068
|
+
const bindIsLocal = bind === "127.0.0.1" || bind === "localhost" || bind === "::1" || bind === "0.0.0.0";
|
|
3069
|
+
const host = parsed.hostname;
|
|
3070
|
+
const valueIsLocal = host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
|
|
3071
|
+
const valuePort = parsed.port === "" ? parsed.protocol === "https:" ? 443 : 80 : Number(parsed.port);
|
|
3072
|
+
return bindIsLocal && valueIsLocal && valuePort === port;
|
|
3073
|
+
} catch {
|
|
3074
|
+
return false;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
2628
3077
|
var PermissionRulesRuntime = class {
|
|
2629
3078
|
ctx;
|
|
2630
3079
|
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
@@ -2645,6 +3094,11 @@ var PermissionRulesRuntime = class {
|
|
|
2645
3094
|
inFlight = /* @__PURE__ */ new Map();
|
|
2646
3095
|
/** The network proxy, when the policy is enabled and mounted. */
|
|
2647
3096
|
networkProxy;
|
|
3097
|
+
/** Ambient upstream candidates, captured ONCE before this plugin injects its own (see {@link captureAmbientUpstream}). */
|
|
3098
|
+
ambientUpstream;
|
|
3099
|
+
ambientCaptured = false;
|
|
3100
|
+
upstreamWarned = false;
|
|
3101
|
+
selfLoopWarned = false;
|
|
2648
3102
|
/** Restores the injected proxy environment (set after a successful bind). */
|
|
2649
3103
|
envRestore;
|
|
2650
3104
|
/** The authoritative config source: the composition entry, replaced by the settings scope while attached. */
|
|
@@ -3229,6 +3683,7 @@ var PermissionRulesRuntime = class {
|
|
|
3229
3683
|
*/
|
|
3230
3684
|
async attachNetworkProxy() {
|
|
3231
3685
|
const cfg = this.config.network;
|
|
3686
|
+
this.captureAmbientUpstream();
|
|
3232
3687
|
const proxy = new NetworkProxy({
|
|
3233
3688
|
bind: cfg.proxyBind,
|
|
3234
3689
|
port: cfg.proxyPort,
|
|
@@ -3236,7 +3691,8 @@ var PermissionRulesRuntime = class {
|
|
|
3236
3691
|
decide: (target) => this.decideProxyTarget(target),
|
|
3237
3692
|
attribution: () => this.proxyAttribution(),
|
|
3238
3693
|
onBlock: (record, attribution) => this.auditNetworkBlock(record, attribution),
|
|
3239
|
-
logger: this.ctx.logger
|
|
3694
|
+
logger: this.ctx.logger,
|
|
3695
|
+
upstream: () => this.upstreamFor()
|
|
3240
3696
|
});
|
|
3241
3697
|
this.networkProxy = proxy;
|
|
3242
3698
|
this.ctx.effect(() => () => {
|
|
@@ -3273,6 +3729,78 @@ var PermissionRulesRuntime = class {
|
|
|
3273
3729
|
this.envRestore = void 0;
|
|
3274
3730
|
await this.attachNetworkProxy();
|
|
3275
3731
|
}
|
|
3732
|
+
/**
|
|
3733
|
+
* Capture the launch environment's proxy names exactly ONCE, before this
|
|
3734
|
+
* plugin injects its own. `onNetworkConfigChanged()` closes the old proxy and
|
|
3735
|
+
* restores the environment around a rebind, so a second capture could read
|
|
3736
|
+
* this plugin's own address back as if the operator had exported it — hence
|
|
3737
|
+
* the memo. The launch snapshot is immutable and preferred; `process.env` is
|
|
3738
|
+
* the fallback for hosts that expose no snapshot.
|
|
3739
|
+
*/
|
|
3740
|
+
captureAmbientUpstream() {
|
|
3741
|
+
if (this.ambientCaptured) return;
|
|
3742
|
+
this.ambientCaptured = true;
|
|
3743
|
+
const launch = this.ctx.get("launchEnvironment");
|
|
3744
|
+
const lookup = (name) => launch?.get(name)?.value ?? process.env[name];
|
|
3745
|
+
this.ambientUpstream = readAmbientProxy(lookup);
|
|
3746
|
+
this.warnAboutUpstream();
|
|
3747
|
+
}
|
|
3748
|
+
/** The one-shot upstream diagnostics: a discarded ambient proxy, or an `inherit` with nothing to inherit. */
|
|
3749
|
+
warnAboutUpstream() {
|
|
3750
|
+
if (this.upstreamWarned) return;
|
|
3751
|
+
this.upstreamWarned = true;
|
|
3752
|
+
const cfg = this.config.network;
|
|
3753
|
+
const ambient = this.ambientUpstream;
|
|
3754
|
+
const has = ambient !== void 0 && (ambient.http !== void 0 || ambient.https !== void 0);
|
|
3755
|
+
if (cfg.upstreamProxy === "off" && cfg.injectEnv && has) this.ctx.logger.warn(`permission-rules: an ambient proxy (${redactProxyUrl(ambient.https ?? ambient.http ?? "")}) is discarded for traffic through this proxy; set network.upstreamProxy to "inherit" to chain through it, or leave it to subprocesses only`);
|
|
3756
|
+
if (cfg.upstreamProxy === "inherit" && !cfg.injectEnv) this.ctx.logger.warn("permission-rules: network.upstreamProxy is only consulted for connections that reach this proxy; with injectEnv false, subprocess traffic keeps its own route");
|
|
3757
|
+
if (cfg.upstreamProxy === "inherit" && !has) this.ctx.logger.warn("permission-rules: network.upstreamProxy is \"inherit\" but the launch environment named no usable http(s) proxy (a SOCKS or malformed value is ignored; Node has no SOCKS client)");
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* The upstream getter the proxy consults per connection. Undefined when the
|
|
3761
|
+
* setting is `off`, when no candidate is usable, or when the candidate points
|
|
3762
|
+
* back into this very proxy — a self-loop would recurse forever, so chaining
|
|
3763
|
+
* is disabled for it and warned about once.
|
|
3764
|
+
*/
|
|
3765
|
+
upstreamFor() {
|
|
3766
|
+
if (this.config.network.upstreamProxy === "off") return void 0;
|
|
3767
|
+
const ambient = this.ambientUpstream;
|
|
3768
|
+
if (ambient === void 0) return void 0;
|
|
3769
|
+
const selfPort = this.networkProxy?.port ?? 0;
|
|
3770
|
+
const selfHost = this.config.network.proxyBind;
|
|
3771
|
+
const usable = (value) => {
|
|
3772
|
+
if (value === void 0) return void 0;
|
|
3773
|
+
if (selfPort > 0 && isSelfUpstream(value, selfHost, selfPort)) {
|
|
3774
|
+
if (!this.selfLoopWarned) {
|
|
3775
|
+
this.selfLoopWarned = true;
|
|
3776
|
+
this.ctx.logger.warn(`permission-rules: network.upstreamProxy points at this proxy itself (${redactProxyUrl(value)}); chaining is disabled for it`);
|
|
3777
|
+
}
|
|
3778
|
+
return;
|
|
3779
|
+
}
|
|
3780
|
+
return value;
|
|
3781
|
+
};
|
|
3782
|
+
const http = usable(ambient.http);
|
|
3783
|
+
const https = usable(ambient.https);
|
|
3784
|
+
if (http === void 0 && https === void 0) return void 0;
|
|
3785
|
+
return {
|
|
3786
|
+
...http === void 0 ? {} : { http },
|
|
3787
|
+
...https === void 0 ? {} : { https }
|
|
3788
|
+
};
|
|
3789
|
+
}
|
|
3790
|
+
/** The upstream block of the network snapshot, already redacted. */
|
|
3791
|
+
upstreamSnapshot() {
|
|
3792
|
+
const setting = this.config.network.upstreamProxy;
|
|
3793
|
+
const mode = setting === "off" ? "off" : setting === "inherit" ? "inherit" : "url";
|
|
3794
|
+
const http = mode === "off" ? void 0 : mode === "url" ? setting : this.ambientUpstream?.http;
|
|
3795
|
+
const https = mode === "off" ? void 0 : mode === "url" ? setting : this.ambientUpstream?.https;
|
|
3796
|
+
return {
|
|
3797
|
+
mode,
|
|
3798
|
+
http: http === void 0 ? null : redactProxyUrl(http),
|
|
3799
|
+
https: https === void 0 ? null : redactProxyUrl(https),
|
|
3800
|
+
active: mode !== "off" && this.upstreamFor() !== void 0,
|
|
3801
|
+
chained: this.networkProxy?.chainedConnections() ?? 0
|
|
3802
|
+
};
|
|
3803
|
+
}
|
|
3276
3804
|
/** The network snapshot the settings page and `/rules network` render. */
|
|
3277
3805
|
networkSnapshot() {
|
|
3278
3806
|
const cfg = this.config.network;
|
|
@@ -3291,16 +3819,18 @@ var PermissionRulesRuntime = class {
|
|
|
3291
3819
|
proxyActive: proxy !== void 0 && proxy.port > 0,
|
|
3292
3820
|
denied: stats.denied,
|
|
3293
3821
|
askBlocked: stats.askBlocked,
|
|
3294
|
-
recent: proxy?.recentBlocks() ?? []
|
|
3822
|
+
recent: proxy?.recentBlocks() ?? [],
|
|
3823
|
+
upstream: this.upstreamSnapshot(),
|
|
3824
|
+
allowHostAction: cfg.allowHostAction
|
|
3295
3825
|
};
|
|
3296
3826
|
}
|
|
3297
|
-
/**
|
|
3298
|
-
*
|
|
3299
|
-
*
|
|
3300
|
-
*
|
|
3301
|
-
*
|
|
3302
|
-
*
|
|
3303
|
-
*
|
|
3827
|
+
/** The rule-file paths the settings-page editor may read or write: every
|
|
3828
|
+
* currently loaded source plus the per-workspace project files, the
|
|
3829
|
+
* configured fallback, and the host-level project file the session-less
|
|
3830
|
+
* chain resolves (so a not-yet-existing project file can be created). The
|
|
3831
|
+
* editor never touches arbitrary paths — and the built-in baseline is
|
|
3832
|
+
* excluded entirely (it is a shipped, read-only baseline, shown via
|
|
3833
|
+
* `/rules` attribution instead).
|
|
3304
3834
|
*/
|
|
3305
3835
|
knownRuleSources() {
|
|
3306
3836
|
const cfg = this.config;
|
|
@@ -3315,6 +3845,7 @@ var PermissionRulesRuntime = class {
|
|
|
3315
3845
|
}
|
|
3316
3846
|
if (cfg.fallbackPath !== void 0) set.add(isAbsolute(cfg.fallbackPath) ? cfg.fallbackPath : resolve(cfg.fallbackPath));
|
|
3317
3847
|
if (isAbsolute(cfg.rulesFile)) set.add(cfg.rulesFile);
|
|
3848
|
+
else set.add(join(process.cwd(), cfg.rulesFile));
|
|
3318
3849
|
return [...set];
|
|
3319
3850
|
}
|
|
3320
3851
|
/** Read one known rule file for the editor: `{ exists, text, error }`. */
|
|
@@ -3404,6 +3935,117 @@ var PermissionRulesRuntime = class {
|
|
|
3404
3935
|
reloaded
|
|
3405
3936
|
};
|
|
3406
3937
|
}
|
|
3938
|
+
/**
|
|
3939
|
+
* The settings-page "allow this host" action (issue #19 item 3): write ONE
|
|
3940
|
+
* minimal `match.network.domains` allow rule for a blocked target into the
|
|
3941
|
+
* nearest effective rule file, at index 0, then report the decision the
|
|
3942
|
+
* runtime makes for that target afterwards.
|
|
3943
|
+
*
|
|
3944
|
+
* Why index 0: rules are first-match-wins, so appending the rule after an
|
|
3945
|
+
* existing `deny` would leave the connection blocked — the rule would be
|
|
3946
|
+
* dead text the operator cannot see the reason for.
|
|
3947
|
+
*
|
|
3948
|
+
* Why `domains` only: that dimension is subdomain-inclusive and
|
|
3949
|
+
* port/scheme-agnostic; narrowing the generated rule to the blocked port
|
|
3950
|
+
* would re-block the same host on its next connection from another port.
|
|
3951
|
+
* The rule can never widen TOOL permissions either — an allow hit on
|
|
3952
|
+
* `tools/pre-execute` is delegated downstream, never claimed.
|
|
3953
|
+
*
|
|
3954
|
+
* Failure modes are fail-closed and leave the disk untouched: the action is
|
|
3955
|
+
* disabled, the host is not a host name/IP literal, the request names an
|
|
3956
|
+
* unknown workspace, the target resolves outside the known rule sources or
|
|
3957
|
+
* onto the built-in baseline, the file cannot be read, the document does
|
|
3958
|
+
* not parse / has no `rules` list, or the written text fails the same
|
|
3959
|
+
* `parseRulesDocument` + `compileRules` gate every hand edit passes.
|
|
3960
|
+
*
|
|
3961
|
+
* The write goes through {@link saveRuleFile}, so the per-workspace chains
|
|
3962
|
+
* AND the session-less host chain are re-read — no restart, no
|
|
3963
|
+
* `/rules reload` — and the returned `outcome` is the REAL decision
|
|
3964
|
+
* recomputed after the write (with the target as given, no DNS: an
|
|
3965
|
+
* `ips`-scoped allow rule cannot be seen here). Cross-process concurrent
|
|
3966
|
+
* writes to one rule file are out of scope: last writer wins.
|
|
3967
|
+
*
|
|
3968
|
+
* @param request - the blocked target plus the workspace it belongs to.
|
|
3969
|
+
* @returns the action result, never a throw.
|
|
3970
|
+
*/
|
|
3971
|
+
allowHost(request) {
|
|
3972
|
+
const fail = (error, path = null) => ({
|
|
3973
|
+
ok: false,
|
|
3974
|
+
path,
|
|
3975
|
+
created: false,
|
|
3976
|
+
reloaded: 0,
|
|
3977
|
+
outcome: null,
|
|
3978
|
+
alreadyAllowed: false,
|
|
3979
|
+
error
|
|
3980
|
+
});
|
|
3981
|
+
if (!this.config.network.allowHostAction) return fail("the allow-host action is disabled by network.allowHostAction");
|
|
3982
|
+
const host = normalizeAllowHost(request.host);
|
|
3983
|
+
if (!isWritableHost(host)) return fail(`refusing to allow ${JSON.stringify(request.host)}: not a host name or IP literal`);
|
|
3984
|
+
const target = {
|
|
3985
|
+
scheme: request.scheme ?? void 0,
|
|
3986
|
+
host,
|
|
3987
|
+
port: request.port ?? void 0,
|
|
3988
|
+
ips: []
|
|
3989
|
+
};
|
|
3990
|
+
if (this.decideProxyTarget(target).action === "allow") return {
|
|
3991
|
+
ok: true,
|
|
3992
|
+
path: null,
|
|
3993
|
+
created: false,
|
|
3994
|
+
reloaded: 0,
|
|
3995
|
+
outcome: "allow",
|
|
3996
|
+
alreadyAllowed: true,
|
|
3997
|
+
error: null
|
|
3998
|
+
};
|
|
3999
|
+
let path;
|
|
4000
|
+
try {
|
|
4001
|
+
path = resolveAllowHostTarget({
|
|
4002
|
+
cwd: request.cwd,
|
|
4003
|
+
loadedCwds: [...this.byCwd.values()].map((loaded) => loaded.cwd),
|
|
4004
|
+
knownSources: this.knownRuleSources(),
|
|
4005
|
+
rulesFile: this.config.rulesFile,
|
|
4006
|
+
fallbackPath: this.config.fallbackPath,
|
|
4007
|
+
processCwd: process.cwd(),
|
|
4008
|
+
builtinPath: this.builtinSource(),
|
|
4009
|
+
exists: existsSync
|
|
4010
|
+
});
|
|
4011
|
+
} catch (error) {
|
|
4012
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
4013
|
+
}
|
|
4014
|
+
const existed = existsSync(path);
|
|
4015
|
+
let existing = "";
|
|
4016
|
+
if (existed) try {
|
|
4017
|
+
existing = readFileSync(path, "utf8");
|
|
4018
|
+
} catch (error) {
|
|
4019
|
+
return fail(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`, path);
|
|
4020
|
+
}
|
|
4021
|
+
let edit;
|
|
4022
|
+
try {
|
|
4023
|
+
edit = insertAllowRule(existing, host);
|
|
4024
|
+
} catch (error) {
|
|
4025
|
+
return fail(error instanceof Error ? error.message : String(error), path);
|
|
4026
|
+
}
|
|
4027
|
+
if (!edit.inserted) return {
|
|
4028
|
+
ok: true,
|
|
4029
|
+
path,
|
|
4030
|
+
created: false,
|
|
4031
|
+
reloaded: 0,
|
|
4032
|
+
outcome: this.decideProxyTarget(target).action,
|
|
4033
|
+
alreadyAllowed: true,
|
|
4034
|
+
error: null
|
|
4035
|
+
};
|
|
4036
|
+
const hadHostChain = this.hostLoaded !== void 0;
|
|
4037
|
+
const saved = this.saveRuleFile(path, edit.text);
|
|
4038
|
+
if (!saved.ok) return fail(saved.error ?? `refusing to write ${path}`, path);
|
|
4039
|
+
return {
|
|
4040
|
+
ok: true,
|
|
4041
|
+
path,
|
|
4042
|
+
created: !existed,
|
|
4043
|
+
reloaded: (saved.reloaded ?? 0) + (hadHostChain ? 1 : 0),
|
|
4044
|
+
outcome: this.decideProxyTarget(target).action,
|
|
4045
|
+
alreadyAllowed: false,
|
|
4046
|
+
error: null
|
|
4047
|
+
};
|
|
4048
|
+
}
|
|
3407
4049
|
/** The installed `@deepseek-ai/dsh-session` version, or `null` when unresolvable (falls back to the append probe). */
|
|
3408
4050
|
peerVersion() {
|
|
3409
4051
|
try {
|
|
@@ -3638,6 +4280,7 @@ var PermissionRulesRuntime = class {
|
|
|
3638
4280
|
}
|
|
3639
4281
|
lines.push(prose.networkHeader(snapshot.mode, snapshot.sandboxMode, snapshot.configuredMode, snapshot.proxyActive, snapshot.proxyPort));
|
|
3640
4282
|
lines.push(prose.networkCounters(snapshot.denied, snapshot.askBlocked));
|
|
4283
|
+
lines.push(prose.networkUpstream(snapshot.upstream.mode, snapshot.upstream.http, snapshot.upstream.https, snapshot.upstream.active, snapshot.upstream.chained));
|
|
3641
4284
|
if (snapshot.recent.length === 0) lines.push(prose.noNetworkBlocks);
|
|
3642
4285
|
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));
|
|
3643
4286
|
return {
|
|
@@ -4088,4 +4731,4 @@ const name = "permission-rules";
|
|
|
4088
4731
|
/** Consumer — the /rules command handler and the tools/pre-execute waterfall listener consume the injected commands/tools services. */
|
|
4089
4732
|
const inject = ["commands", "tools"];
|
|
4090
4733
|
//#endregion
|
|
4091
|
-
export { BUILTIN_RULES_FILE, Config, GlobError, NETWORK_MODES, NO_PROXY_ENV_NAMES, NetworkProxy, PATH_CANDIDATE_KEYS, PLATFORMS, PROXY_ENV_NAMES, PermissionRulesRuntime, RULE_ACTIONS, RuleError, SCHEMES, SHIPPED_BUILTIN_RULES, URL_CANDIDATE_KEYS, apply, blockMessage, compileGlob, compilePatternRegex, compileRules, compileRulesChain, compileSourceRules, decideNetworkTarget, decomposeShellCommand, defaultDecision, describeRule, extractPathCandidates, extractUrlCandidates, findUnreachableRules, inject, injectProxyEnv, isIpLiteral, isLoopbackTarget, isMarkedAuditEvent, isShellTool, isUnmarkedHostVersion, matchRules, matchTools, matchWhen, mergeCompiledRules, name, networkModeForSandbox, normalizeWorkspacePath, parseRulesDocument, parseUrlTarget, resolveBuiltinRulesPath, resolveConfig, targetMatchesNetwork };
|
|
4734
|
+
export { BUILTIN_RULES_FILE, Config, GlobError, NETWORK_MODES, NO_PROXY_ENV_NAMES, NetworkProxy, PATH_CANDIDATE_KEYS, PLATFORMS, PROXY_ENV_NAMES, PermissionRulesRuntime, RULE_ACTIONS, RuleError, SCHEMES, SHIPPED_BUILTIN_RULES, URL_CANDIDATE_KEYS, apply, blockMessage, compileGlob, compilePatternRegex, compileRules, compileRulesChain, compileSourceRules, decideNetworkTarget, decomposeShellCommand, defaultDecision, describeRule, extractPathCandidates, extractUrlCandidates, findUnreachableRules, inject, injectProxyEnv, isIpLiteral, isLoopbackTarget, isMarkedAuditEvent, isShellTool, isUnmarkedHostVersion, matchRules, matchTools, matchWhen, mergeCompiledRules, name, networkModeForSandbox, normalizeHost, normalizeWorkspacePath, parseRulesDocument, parseUrlTarget, readAmbientProxy, redactProxyUrl, resolveBuiltinRulesPath, resolveConfig, targetMatchesNetwork };
|