dsh-permission-rules 0.6.21 → 0.7.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.es.md +6 -1
  3. package/README.hi.md +6 -1
  4. package/README.md +6 -1
  5. package/README.pt.md +6 -1
  6. package/README.zh.md +6 -1
  7. package/docs/rules-format.en.md +2 -1
  8. package/docs/rules-format.md +2 -1
  9. package/lib/client.js +6924 -37
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +671 -28
  12. package/lib/typert.host.js +74 -3
  13. package/lib/types/allow-host.d.ts +151 -0
  14. package/lib/types/allow-host.d.ts.map +1 -0
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/locales.d.ts +1 -1
  17. package/lib/types/client/locales.d.ts.map +1 -1
  18. package/lib/types/client/remote.d.ts +54 -1
  19. package/lib/types/client/remote.d.ts.map +1 -1
  20. package/lib/types/client/section.d.ts +3 -1
  21. package/lib/types/client/section.d.ts.map +1 -1
  22. package/lib/types/config.d.ts +22 -0
  23. package/lib/types/config.d.ts.map +1 -1
  24. package/lib/types/prose.d.ts +1 -0
  25. package/lib/types/prose.d.ts.map +1 -1
  26. package/lib/types/proxy.d.ts +73 -0
  27. package/lib/types/proxy.d.ts.map +1 -1
  28. package/lib/types/remote-service.d.ts +9 -1
  29. package/lib/types/remote-service.d.ts.map +1 -1
  30. package/lib/types/rules.d.ts +11 -0
  31. package/lib/types/rules.d.ts.map +1 -1
  32. package/lib/types/runtime.d.ts +76 -11
  33. package/lib/types/runtime.d.ts.map +1 -1
  34. package/lib/types/typert.host.d.ts +50 -0
  35. package/lib/types/typert.host.d.ts.map +1 -1
  36. package/lib/types/wire.d.ts +191 -0
  37. package/lib/types/wire.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/allow-host.ts +307 -0
  40. package/src/client/index.ts +7 -1
  41. package/src/client/locales.ts +36 -0
  42. package/src/client/remote.ts +4 -1
  43. package/src/client/section.ts +83 -10
  44. package/src/config.ts +56 -0
  45. package/src/prose.ts +6 -0
  46. package/src/proxy.ts +202 -16
  47. package/src/remote-service.ts +33 -1
  48. package/src/rules.ts +11 -2
  49. package/src/runtime.ts +243 -9
  50. 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
- /** Lowercase, strip IPv6 brackets, strip one trailing dot. */
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 send = upstream.protocol === "https:" ? request$1 : request;
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
- * The rule-file paths the settings-page editor may read or write: every
3299
- * currently loaded source plus the per-workspace project files and the
3300
- * configured fallback (so a not-yet-existing project file can be
3301
- * created). The editor never touches arbitrary paths — and the built-in
3302
- * baseline is excluded entirely (it is a shipped, read-only baseline,
3303
- * shown via `/rules` attribution instead).
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 };