dsh-permission-rules 0.6.16 → 0.6.18

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 CHANGED
@@ -1,3 +1,28 @@
1
+ ## v0.6.18 - 2026-09-10
2
+
3
+ ### Docs
4
+
5
+ - Five-language READMEs: the network-policy "Matching" bullet now states that IPv4-mapped IPv6 literals are normalized to their IPv4 form before matching, and that the proxy connects on the addresses the decision was made on (never a second DNS resolution). Republished so the npm page carries the updated READMEs.
6
+
7
+ ### Tests
8
+
9
+ - No behavior change in this version: it republishes the 0.6.17 code (which already contains the network hardening below) with the refreshed documentation. Gate chain re-run green (285 tests, 23 files).
10
+
11
+ ## v0.6.17 - 2026-09-10
12
+
13
+ ### Security
14
+
15
+ - **Hardening: the proxy now connects on the adjudicated address, never a second DNS resolution.** The proxy adjudicated a hostname on one `lookup()` and then connected by hostname, re-resolving — a hostname whose answers change between adjudication and connect (DNS rebinding, CWE-367) could reach an address the rules never approved. `decideWithResolution` now returns the target carrying the resolved addresses, CONNECT tunnels try those addresses in order (a 502 when none accepts), and the plain-HTTP forward pins `http(s).request` with a `lookup` answering from the same list, so a multi-address host keeps its fallbacks through happy-eyeballs ordering. End-to-end guards live in `test/proxy.spec.ts` (plain HTTP and CONNECT, literal and CIDR `ips` rules, plus named-target forwarding); `AGENTS.md` and both `rules-format` docs record the behavior.
16
+
17
+ ### Changed
18
+
19
+ - Pin the `@deepseek-ai/dsh-*` dev/test dependencies to the published `0.1.5-rc.1` line and record `0.1.5-rc.1` in `dshWorkshop.compatibility.dshVersions`; the monthly Compat workflow now runs against `0.1.5-rc.1`. The peer range `>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0` is unchanged, so no supported host line is dropped.
20
+ - Vendor integration tarball updated to `dsh-auto-review` 0.12.2 (`vendor/dsh-auto-review-0.12.2.tgz`, packed from the sibling repo at tag `v0.12.2`; the packed `files` are byte-identical to that tag's checkout), with the devDependency `file:` specifier and `pnpm-lock.yaml` refreshed. The sibling's runtime `@deepseek-ai/dsh-*` pins now follow the `0.1.5-rc.1` host line, matching this repo's dev peers. The six historical vendor tarballs (`0.9.0`–`0.12.1`) stay in the tree.
21
+
22
+ ### Docs
23
+
24
+ - Refresh the five-language README compatibility baseline to `dsh-v0.1.5-rc.1` (verified 2026-09-10).
25
+
1
26
  ## v0.6.16 - 2026-09-09
2
27
 
3
28
  ### Security
package/README.es.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  | Surface | Status |
28
28
  |---|---|
29
- | Harness | DeepSeek Harness `dsh-v0.1.5-alpha.1` (adaptado el 2026-09-09, cadena completa de gates + smoke de instalación de profile): su `Session.append` todavía no puede estampar el marcador `ignorable` —verificado en el paquete publicado `0.1.5-alpha.1`, donde el tercer argumento se descarta silenciosamente y el campo del sobre solo sobrevive para la lectura de logs almacenados—, así que toda la línea `0.1.5-alpha` se preverifica como no marcada y la auditoría del registro de sesión queda desactivada por defecto. La línea `0.1.3-alpha` mantiene la misma firma de append surface-only. Las migraciones de log de ambas líneas rechazan eventos de plugin sin clasificar incluso marcados: aplica `strip` a las filas de auditoría v1 antes de que un host `0.1.3-alpha` abra el log y a las v2 antes de que un host `0.1.5-alpha` lo migre (los logs v3 nativos solo necesitan `repair`). |
29
+ | Harness | DeepSeek Harness `dsh-v0.1.5-rc.1` (adaptado el 2026-09-09, cadena completa de gates + smoke de instalación de profile): su `Session.append` todavía no puede estampar el marcador `ignorable` —verificado en el paquete publicado `0.1.5-rc.1`, donde el tercer argumento se descarta silenciosamente y el campo del sobre solo sobrevive para la lectura de logs almacenados—, así que toda la línea `0.1.5-alpha` se preverifica como no marcada y la auditoría del registro de sesión queda desactivada por defecto. La línea `0.1.3-alpha` mantiene la misma firma de append surface-only. Las migraciones de log de ambas líneas rechazan eventos de plugin sin clasificar incluso marcados: aplica `strip` a las filas de auditoría v1 antes de que un host `0.1.3-alpha` abra el log y a las v2 antes de que un host `0.1.5-alpha` lo migre (los logs v3 nativos solo necesitan `repair`). |
30
30
  | Node | `^22.19.0 || >=24.0.0` |
31
31
  | Platforms | Todas (host + cliente web de settings) |
32
32
  | Model | Cualquiera (las razones deny/ask se muestran a través de los resultados de herramienta) |
@@ -76,7 +76,7 @@ Una **política de red a nivel de proceso** estilo Codex: el tráfico de subproc
76
76
  - **`allow-all`** — el preset danger-full-access: permitir todo.
77
77
  - **`auto`** (por defecto) — sigue el preset del sandbox; en hosts sin el servicio de política de sandbox se resuelve a `autoFallback` (`allow-all`).
78
78
 
79
- - **Emparejamiento** — `match.network` con `domains` / `ips` / `ports` / `schemes` (globs, comodines, CIDR, rangos de puertos; se aceptan puertos YAML numéricos). La extracción de candidatos URL en la ruta caliente `tools/pre-execute` se dispara sobre argumentos de herramientas web y URLs embebidas en texto de comandos bash/pwsh; los destinos de loopback pueden cortocircuitar reglas según la política `loopback`.
79
+ - **Emparejamiento** — `match.network` con `domains` / `ips` / `ports` / `schemes` (globs, comodines, CIDR, rangos de puertos; se aceptan puertos YAML numéricos). La extracción de candidatos URL en la ruta caliente `tools/pre-execute` se dispara sobre argumentos de herramientas web y URLs embebidas en texto de comandos bash/pwsh; los destinos de loopback pueden cortocircuitar reglas según la política `loopback`. Los literales IPv6 mapeados a IPv4 se normalizan a su forma IPv4 antes de comparar, y el proxy se conecta a las direcciones con las que se tomó la decisión, sin una segunda resolución DNS.
80
80
  - **Auditoría** — las conexiones denegadas anexan `permissionRules/network` a la sesión propietaria (la misma puerta adaptativa `ignorable`), con contadores de bloqueo e intercepciones recientes en `/rules network` y la página de settings.
81
81
 
82
82
  ## Quick start
package/README.hi.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  | Surface | Status |
28
28
  |---|---|
29
- | Harness | DeepSeek Harness `dsh-v0.1.5-alpha.1` (2026-09-09 को अनुकूलित, पूर्ण gates शृंखला + profile इंस्टॉल smoke): इसका `Session.append` अभी भी `ignorable` मार्कर स्टैम्प नहीं कर सकता — प्रकाशित `0.1.5-alpha.1` पैकेज पर सत्यापित, जहाँ तीसरा आर्ग्युमेंट चुपचाप छोड़ दिया जाता है और लिफ़ाफ़ा फ़ील्ड केवल संग्रहीत-लॉग पठन के लिए बचता है — इसलिए पूरी `0.1.5-alpha` पंक्ति पहले append से पहले ही अनमार्क्ड के रूप में पूर्व-जाँची जाती है और सत्र-लॉग ऑडिट डिफ़ॉल्ट रूप से बंद रहता है। `0.1.3-alpha` पंक्ति वही surface-only append हस्ताक्षर रखती है। दोनों पंक्तियों का लॉग प्रवासन चिह्नित अन-क्लासिफ़ाइड प्लगइन घटनाओं को भी अस्वीकारता है: `0.1.3-alpha` host के लॉग खोलने से पहले v1 ऑडिट पंक्तियाँ और `0.1.5-alpha` host के प्रवासन से पहले v2 पंक्तियाँ `strip` करें (v3 मूल लॉग के लिए केवल `repair` चाहिए)। |
29
+ | Harness | DeepSeek Harness `dsh-v0.1.5-rc.1` (2026-09-09 को अनुकूलित, पूर्ण gates शृंखला + profile इंस्टॉल smoke): इसका `Session.append` अभी भी `ignorable` मार्कर स्टैम्प नहीं कर सकता — प्रकाशित `0.1.5-rc.1` पैकेज पर सत्यापित, जहाँ तीसरा आर्ग्युमेंट चुपचाप छोड़ दिया जाता है और लिफ़ाफ़ा फ़ील्ड केवल संग्रहीत-लॉग पठन के लिए बचता है — इसलिए पूरी `0.1.5-alpha` पंक्ति पहले append से पहले ही अनमार्क्ड के रूप में पूर्व-जाँची जाती है और सत्र-लॉग ऑडिट डिफ़ॉल्ट रूप से बंद रहता है। `0.1.3-alpha` पंक्ति वही surface-only append हस्ताक्षर रखती है। दोनों पंक्तियों का लॉग प्रवासन चिह्नित अन-क्लासिफ़ाइड प्लगइन घटनाओं को भी अस्वीकारता है: `0.1.3-alpha` host के लॉग खोलने से पहले v1 ऑडिट पंक्तियाँ और `0.1.5-alpha` host के प्रवासन से पहले v2 पंक्तियाँ `strip` करें (v3 मूल लॉग के लिए केवल `repair` चाहिए)। |
30
30
  | Node | `^22.19.0 || >=24.0.0` |
31
31
  | Platforms | सभी (host + वेब settings क्लाइंट) |
32
32
  | Model | कोई भी (deny/ask कारण टूल परिणामों के माध्यम से दिखते हैं) |
@@ -76,7 +76,7 @@ Codex-शैली की **प्रक्रिया-स्तरीय न
76
76
  - **`allow-all`** — danger-full-access preset: सब कुछ अनुमत।
77
77
  - **`auto`** (डिफ़ॉल्ट) — sandbox preset का अनुसरण करता है; बिना sandbox-नीति सेवा वाले hosts पर `autoFallback` (`allow-all`) में हल होता है।
78
78
 
79
- - **मिलान** — `match.network` `domains` / `ips` / `ports` / `schemes` के साथ (globs, वाइल्डकार्ड, CIDR, पोर्ट श्रेणियाँ; संख्यात्मक YAML पोर्ट स्वीकृत)। `tools/pre-execute` हॉट पथ पर URL-उम्मीदवार निष्कर्षण वेब-टूल तर्कों और bash/pwsh कमांड टेक्स्ट में एम्बेडेड URLs पर चलता है; लूपबैक लक्ष्य `loopback` नीति के अनुसार नियमों को शॉर्ट-सर्किट कर सकते हैं।
79
+ - **मिलान** — `match.network` `domains` / `ips` / `ports` / `schemes` के साथ (globs, वाइल्डकार्ड, CIDR, पोर्ट श्रेणियाँ; संख्यात्मक YAML पोर्ट स्वीकृत)। `tools/pre-execute` हॉट पथ पर URL-उम्मीदवार निष्कर्षण वेब-टूल तर्कों और bash/pwsh कमांड टेक्स्ट में एम्बेडेड URLs पर चलता है; लूपबैक लक्ष्य `loopback` नीति के अनुसार नियमों को शॉर्ट-सर्किट कर सकते हैं। IPv4-मैप्ड IPv6 लिटरल तुलना से पहले IPv4 रूप में सामान्यीकृत होते हैं, और प्रॉक्सी उन्हीं पतों पर कनेक्ट करता है जिन पर निर्णय लिया गया — दूसरा DNS रिज़ॉल्यूशन नहीं।
80
80
  - **ऑडिट** — अस्वीकृत कनेक्शन स्वामी सत्र में `permissionRules/network` जोड़ते हैं (वही अनुकूली `ignorable` द्वार), `/rules network` और settings पृष्ठ में ब्लॉक काउंटर व हाल की अवरोधन के साथ।
81
81
 
82
82
  ## Quick start
package/README.md CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  | Surface | Status |
29
29
  |---|---|
30
- | Harness | DeepSeek Harness `dsh-v0.1.5-alpha.1` (adapted 2026-09-09, full gate chain + profile install smoke): its `Session.append` still cannot stamp the `ignorable` marker — verified on the published `0.1.5-alpha.1` package, where the third argument is silently dropped and the envelope field survives for stored-log reads only — so the whole `0.1.5-alpha` line is pre-checked as unmarked and session-log audit stays disabled by default. The `0.1.3-alpha` line keeps the same surface-only append signature. Both lines' log migrations refuse unclassified plugin events even when marked, so `strip` v1 audit rows before a `0.1.3-alpha` host opens the log and v2 audit rows before a `0.1.5-alpha` host migrates it (native v3 logs only need `repair`). |
30
+ | Harness | DeepSeek Harness `dsh-v0.1.5-rc.1` (adapted 2026-09-09, full gate chain + profile install smoke): its `Session.append` still cannot stamp the `ignorable` marker — verified on the published `0.1.5-rc.1` package, where the third argument is silently dropped and the envelope field survives for stored-log reads only — so the whole `0.1.5-alpha` line is pre-checked as unmarked and session-log audit stays disabled by default. The `0.1.3-alpha` line keeps the same surface-only append signature. Both lines' log migrations refuse unclassified plugin events even when marked, so `strip` v1 audit rows before a `0.1.3-alpha` host opens the log and v2 audit rows before a `0.1.5-alpha` host migrates it (native v3 logs only need `repair`). |
31
31
  | Node | `^22.19.0 || >=24.0.0` |
32
32
  | Platforms | All (host + web settings client) |
33
33
  | Model | Any (deny/ask reasons surface through tool results) |
@@ -77,7 +77,7 @@ A Codex-style **process-level network policy**: shell subprocess traffic flows t
77
77
  - **`allow-all`** — the danger-full-access preset: allow everything.
78
78
  - **`auto`** (default) — follows the sandbox preset; on hosts without the sandbox-policy service it resolves to `autoFallback` (`allow-all`).
79
79
 
80
- - **Matching** — `match.network` with `domains` / `ips` / `ports` / `schemes` (globs, wildcards, CIDRs, port ranges; numeric YAML ports are accepted). URL-candidate extraction on the `tools/pre-execute` hot path fires on web-tool arguments and URLs embedded in bash/pwsh command text; loopback targets can short-circuit rules per `loopback` policy.
80
+ - **Matching** — `match.network` with `domains` / `ips` / `ports` / `schemes` (globs, wildcards, CIDRs, port ranges; numeric YAML ports are accepted). URL-candidate extraction on the `tools/pre-execute` hot path fires on web-tool arguments and URLs embedded in bash/pwsh command text; loopback targets can short-circuit rules per `loopback` policy. IPv4-mapped IPv6 literals are normalized to their IPv4 form before matching, and the proxy connects on the addresses the decision was made on — never a second DNS resolution.
81
81
  - **Audit** — denied connections append `permissionRules/network` to the owning session (same adaptive `ignorable` gate), with block counters and recent interceptions in `/rules network` and the settings page.
82
82
 
83
83
  ## Quick start
package/README.pt.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  | Surface | Status |
28
28
  |---|---|
29
- | Harness | DeepSeek Harness `dsh-v0.1.5-alpha.1` (adaptado em 2026-09-09, cadeia completa de gates + smoke de instalação de profile): o `Session.append` ainda não consegue estampar o marcador `ignorable` — verificado no pacote publicado `0.1.5-alpha.1`, onde o terceiro argumento é descartado silenciosamente e o campo do envelope só sobrevive para a leitura de logs armazenados —, então toda a linha `0.1.5-alpha` é pré-verificada como não marcada e a auditoria do log de sessão fica desativada por padrão. A linha `0.1.3-alpha` mantém a mesma assinatura de append surface-only. As migrações de log de ambas as linhas recusam eventos de plugin não classificados mesmo marcados: aplique `strip` nas linhas de auditoria v1 antes de um host `0.1.3-alpha` abrir o log e nas v2 antes de um host `0.1.5-alpha` migrá-lo (logs v3 nativos só precisam de `repair`). |
29
+ | Harness | DeepSeek Harness `dsh-v0.1.5-rc.1` (adaptado em 2026-09-09, cadeia completa de gates + smoke de instalação de profile): o `Session.append` ainda não consegue estampar o marcador `ignorable` — verificado no pacote publicado `0.1.5-rc.1`, onde o terceiro argumento é descartado silenciosamente e o campo do envelope só sobrevive para a leitura de logs armazenados —, então toda a linha `0.1.5-alpha` é pré-verificada como não marcada e a auditoria do log de sessão fica desativada por padrão. A linha `0.1.3-alpha` mantém a mesma assinatura de append surface-only. As migrações de log de ambas as linhas recusam eventos de plugin não classificados mesmo marcados: aplique `strip` nas linhas de auditoria v1 antes de um host `0.1.3-alpha` abrir o log e nas v2 antes de um host `0.1.5-alpha` migrá-lo (logs v3 nativos só precisam de `repair`). |
30
30
  | Node | `^22.19.0 || >=24.0.0` |
31
31
  | Platforms | Todas (host + cliente web de settings) |
32
32
  | Model | Qualquer (razões deny/ask aparecem pelos resultados de ferramenta) |
@@ -76,7 +76,7 @@ Uma **política de rede em nível de processo** estilo Codex: o tráfego de subp
76
76
  - **`allow-all`** — o preset danger-full-access: permitir tudo.
77
77
  - **`auto`** (padrão) — segue o preset do sandbox; em hosts sem o serviço de política de sandbox resolve para `autoFallback` (`allow-all`).
78
78
 
79
- - **Correspondência** — `match.network` com `domains` / `ips` / `ports` / `schemes` (globs, curingas, CIDR, faixas de porta; portas YAML numéricas são aceitas). A extração de candidatos URL na rota quente `tools/pre-execute` dispara sobre argumentos de ferramentas web e URLs embutidas em texto de comando bash/pwsh; destinos de loopback podem curto-circuitar regras conforme a política `loopback`.
79
+ - **Correspondência** — `match.network` com `domains` / `ips` / `ports` / `schemes` (globs, curingas, CIDR, faixas de porta; portas YAML numéricas são aceitas). A extração de candidatos URL na rota quente `tools/pre-execute` dispara sobre argumentos de ferramentas web e URLs embutidas em texto de comando bash/pwsh; destinos de loopback podem curto-circuitar regras conforme a política `loopback`. Literais IPv6 mapeados em IPv4 são normalizados para a forma IPv4 antes da comparação, e o proxy conecta nos endereços usados na decisão, sem uma segunda resolução DNS.
80
80
  - **Auditoria** — conexões negadas anexam `permissionRules/network` à sessão proprietária (a mesma porta adaptativa `ignorable`), com contadores de bloqueio e intercepções recentes em `/rules network` e na página de settings.
81
81
 
82
82
  ## Quick start
package/README.zh.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  | Surface | Status |
28
28
  |---|---|
29
- | Harness | DeepSeek Harness `dsh-v0.1.5-alpha.1`(2026-09-09 已适配,完整门禁链 + profile 安装冒烟):其 `Session.append` 仍无法盖章 `ignorable` 标记——已在已发布的 `0.1.5-alpha.1` 包上实测,第三参数被静默丢弃,信封字段仅用于存量日志读取——故整条 `0.1.5-alpha` 线在首次追加前即被预检为未盖章,会话日志审计默认停用。`0.1.3-alpha` 线同样沿用 surface-only 追加签名。两条线的日志迁移连已标记的未分类插件事件也拒绝:在 `0.1.3-alpha` 宿主打开日志前 `strip` 掉 v1 审计行,在 `0.1.5-alpha` 宿主迁移前 `strip` 掉 v2 审计行(v3 原生日志只需 `repair`)。 |
29
+ | Harness | DeepSeek Harness `dsh-v0.1.5-rc.1`(2026-09-09 已适配,完整门禁链 + profile 安装冒烟):其 `Session.append` 仍无法盖章 `ignorable` 标记——已在已发布的 `0.1.5-rc.1` 包上实测,第三参数被静默丢弃,信封字段仅用于存量日志读取——故整条 `0.1.5-alpha` 线在首次追加前即被预检为未盖章,会话日志审计默认停用。`0.1.3-alpha` 线同样沿用 surface-only 追加签名。两条线的日志迁移连已标记的未分类插件事件也拒绝:在 `0.1.3-alpha` 宿主打开日志前 `strip` 掉 v1 审计行,在 `0.1.5-alpha` 宿主迁移前 `strip` 掉 v2 审计行(v3 原生日志只需 `repair`)。 |
30
30
  | Node | `^22.19.0 || >=24.0.0` |
31
31
  | Platforms | 全部(host + Web 设置客户端) |
32
32
  | Model | 任意(deny/ask 原因经工具结果呈现) |
@@ -76,7 +76,7 @@ Codex 风格的**进程级网络策略**:shell 子进程流量经内置本地
76
76
  - **`allow-all`** —— danger-full-access 预设:放行一切。
77
77
  - **`auto`**(默认)—— 跟随沙箱预设;无沙箱策略服务的宿主上解析为 `autoFallback`(`allow-all`)。
78
78
 
79
- - **匹配** —— `match.network` 用 `domains` / `ips` / `ports` / `schemes`(glob、通配符、CIDR、端口范围;数值型 YAML 端口可接受)。`tools/pre-execute` 热路径上的 URL 候选抽取作用于 web 工具参数与嵌入 bash/pwsh 命令文本的 URL;回环目标可按 `loopback` 策略短路规则。
79
+ - **匹配** —— `match.network` 用 `domains` / `ips` / `ports` / `schemes`(glob、通配符、CIDR、端口范围;数值型 YAML 端口可接受)。`tools/pre-execute` 热路径上的 URL 候选抽取作用于 web 工具参数与嵌入 bash/pwsh 命令文本的 URL;回环目标可按 `loopback` 策略短路规则。IPv4 映射的 IPv6 字面量在匹配前归一化为 IPv4 形式;代理按裁决到的地址建连,不做二次 DNS 解析。
80
80
  - **审计** —— 被拒连接向所属会话追加 `permissionRules/network`(同样的自适应 `ignorable` 门),块计数器与近期拦截在 `/rules network` 与设置页展示。
81
81
 
82
82
  ## Quick start
@@ -41,7 +41,7 @@ A rule whose `match` carries a `network` block is a **network rule**. It matches
41
41
  | Field | Semantics |
42
42
  |---|---|
43
43
  | `domains` | Domain patterns, lowercased with a trailing dot stripped. A pattern without wildcards is subdomain-inclusive — `example.com` also matches `api.example.com`; `*.example.com` matches subdomains only; `*`/`**` compile as ordinary globs. ANY pattern matching the target host satisfies the dimension. |
44
- | `ips` | Exact IPv4/IPv6 literal, a glob (`10.0.*.*`), or an IPv4 CIDR (`10.0.0.0/8`). A literal IP in the URL is always a candidate; the proxy additionally resolves hostnames and tests their addresses, while `tools/pre-execute` matches literal IPs only (no DNS in the hot path). An IPv4-mapped IPv6 literal (`::ffff:169.254.169.254`, `::ffff:a9fe:a9fe`, `0:0:0:0:0:ffff:a9fe:a9fe`) normalizes to its dotted IPv4 form for matching, and a rule written in the mapped form also covers the plain IPv4 spelling. ANY match satisfies the dimension. |
44
+ | `ips` | Exact IPv4/IPv6 literal, a glob (`10.0.*.*`), or an IPv4 CIDR (`10.0.0.0/8`). A literal IP in the URL is always a candidate; the proxy additionally resolves hostnames and tests their addresses, then **connects on the adjudicated address** (no second resolution, so DNS rebinding cannot move the connection to an address the rules never approved; a multi-address host falls back in adjudicated order), while `tools/pre-execute` matches literal IPs only (no DNS in the hot path). An IPv4-mapped IPv6 literal (`::ffff:169.254.169.254`, `::ffff:a9fe:a9fe`, `0:0:0:0:0:ffff:a9fe:a9fe`) normalizes to its dotted IPv4 form for matching, and a rule written in the mapped form also covers the plain IPv4 spelling. ANY match satisfies the dimension. |
45
45
  | `ports` | `*`, one port (`443`), or an inclusive range (`8000-9000`); numeric YAML ports are accepted. Evaluated against the effective port (URL port, else 80/443 by scheme). ANY match satisfies the dimension. |
46
46
  | `schemes` | `http` and/or `https`; the target scheme must be one of them. |
47
47
 
@@ -41,7 +41,7 @@ rules: # 规则列表,按书写顺序求值;可以省略或为空
41
41
  | 字段 | 语义 |
42
42
  |---|---|
43
43
  | `domains` | 域名模式,小写化并去掉末尾点。无通配符的模式包含子域——`example.com` 同时命中 `api.example.com`;`*.example.com` 只匹配子域;`*`/`**` 按普通 glob 编译。任一模式命中目标主机即满足该维度。 |
44
- | `ips` | 精确 IPv4/IPv6 字面量、glob(`10.0.*.*`)或 IPv4 CIDR(`10.0.0.0/8`)。URL 中的字面量 IP 始终是候选;代理额外解析主机名并测试其地址,而 `tools/pre-execute` 只匹配字面量 IP(热路径不做 DNS)。IPv4 映射的 IPv6 字面量(`::ffff:169.254.169.254`、`::ffff:a9fe:a9fe`、`0:0:0:0:0:ffff:a9fe:a9fe`)归一化为其 IPv4 点分形式参与匹配,规则写成映射形式时同样覆盖普通 IPv4 写法。任一命中即满足。 |
44
+ | `ips` | 精确 IPv4/IPv6 字面量、glob(`10.0.*.*`)或 IPv4 CIDR(`10.0.0.0/8`)。URL 中的字面量 IP 始终是候选;代理额外解析主机名并测试其地址,随后**按裁决到的地址建连**(不做二次解析,DNS rebinding 无法把连接改到未获批的地址;多地址主机按裁决顺序回退),而 `tools/pre-execute` 只匹配字面量 IP(热路径不做 DNS)。IPv4 映射的 IPv6 字面量(`::ffff:169.254.169.254`、`::ffff:a9fe:a9fe`、`0:0:0:0:0:ffff:a9fe:a9fe`)归一化为其 IPv4 点分形式参与匹配,规则写成映射形式时同样覆盖普通 IPv4 写法。任一命中即满足。 |
45
45
  | `ports` | `*`、单端口(`443`)或闭区间(`8000-9000`);接受数值型 YAML 端口。按有效端口求值(URL 端口,否则按 scheme 取 80/443)。任一命中即满足。 |
46
46
  | `schemes` | `http` 和/或 `https`;目标 scheme 必须是其中之一。 |
47
47
 
@@ -149,7 +149,7 @@
149
149
  "items": { "type": "string", "minLength": 1 }
150
150
  },
151
151
  "ips": {
152
- "description": "IP literals, globs, or IPv4 CIDRs; ANY matching the target address satisfies the dimension.",
152
+ "description": "IP literals, globs, or IPv4 CIDRs; an IPv4-mapped IPv6 literal (::ffff:169.254.169.254, ::ffff:a9fe:a9fe) is unmapped to its dotted IPv4 form before matching, and a mapped pattern also covers the plain IPv4 spelling; ANY matching the target address satisfies the dimension.",
153
153
  "type": "array",
154
154
  "items": { "type": "string", "minLength": 1 }
155
155
  },
package/lib/index.js CHANGED
@@ -2155,23 +2155,59 @@ var NetworkProxy = class {
2155
2155
  res.end("permission-rules proxy: only absolute-form proxy requests are served here\n");
2156
2156
  return;
2157
2157
  }
2158
- await this.forwardOrBlock(res, target, () => {
2159
- const upstream = new URL(req.url);
2160
- const proxyReq = (upstream.protocol === "https:" ? request$1 : request)(upstream, {
2161
- method: req.method,
2162
- headers: req.headers
2163
- }, (proxyRes) => {
2164
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
2165
- proxyRes.pipe(res);
2166
- });
2167
- proxyReq.on("error", (error) => {
2168
- if (!res.headersSent) {
2169
- res.writeHead(502, { "content-type": "text/plain" });
2170
- res.end(`[network: upstream error] ${String(error)}\n`);
2171
- } else res.destroy();
2158
+ const { decision, target: adjudicated } = await this.decideWithResolution(target);
2159
+ if (decision.action !== "allow") {
2160
+ this.recordBlock(decision, target);
2161
+ const body = blockMessage(decision);
2162
+ res.writeHead(403, {
2163
+ "content-type": "text/plain; charset=utf-8",
2164
+ "content-length": Buffer.byteLength(body)
2172
2165
  });
2173
- req.pipe(proxyReq);
2166
+ res.end(body);
2167
+ return;
2168
+ }
2169
+ const upstream = new URL(req.url);
2170
+ const send = upstream.protocol === "https:" ? request$1 : request;
2171
+ const pinned = pinnedAddresses(adjudicated);
2172
+ const proxyReq = send(upstream, {
2173
+ method: req.method,
2174
+ headers: req.headers,
2175
+ ...pinned.length === 0 ? {} : { lookup: pinnedLookup(pinned) }
2176
+ }, (proxyRes) => {
2177
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
2178
+ proxyRes.pipe(res);
2179
+ });
2180
+ proxyReq.on("error", (error) => {
2181
+ if (!res.headersSent) {
2182
+ res.writeHead(502, { "content-type": "text/plain" });
2183
+ res.end(`[network: upstream error] ${String(error)}\n`);
2184
+ } else res.destroy();
2174
2185
  });
2186
+ req.pipe(proxyReq);
2187
+ }
2188
+ /**
2189
+ * Connect to the first adjudicated address that accepts, so the tunnel lands
2190
+ * on an address the decision approved; a hostname target with no resolution
2191
+ * keeps the historical hostname connect. Each candidate is tried in order so
2192
+ * a multi-address host does not lose its fallback.
2193
+ */
2194
+ async connectUpstream(target, port) {
2195
+ const pinned = pinnedAddresses(target);
2196
+ if (pinned.length === 0) return connect(port, target.host);
2197
+ let lastError;
2198
+ for (const address of pinned) try {
2199
+ return await new Promise((resolve, reject) => {
2200
+ const socket = connect(port, address);
2201
+ socket.once("connect", () => resolve(socket));
2202
+ socket.once("error", (error) => {
2203
+ socket.destroy();
2204
+ reject(error instanceof Error ? error : new Error(String(error)));
2205
+ });
2206
+ });
2207
+ } catch (error) {
2208
+ lastError = error;
2209
+ }
2210
+ throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("no adjudicated address accepted the connection");
2175
2211
  }
2176
2212
  /** CONNECT tunneling (HTTPS and friends): adjudicate, then either 403 or an established TCP tunnel. */
2177
2213
  async handleConnect(req, socket, head) {
@@ -2186,14 +2222,21 @@ var NetworkProxy = class {
2186
2222
  socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
2187
2223
  return;
2188
2224
  }
2189
- const decision = await this.decideWithResolution(target);
2225
+ const { decision, target: adjudicated } = await this.decideWithResolution(target);
2190
2226
  if (decision.action !== "allow") {
2191
2227
  this.recordBlock(decision, target);
2192
2228
  const body = blockMessage(decision);
2193
2229
  socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
2194
2230
  return;
2195
2231
  }
2196
- const tunnel = connect(target.port ?? 443, target.host);
2232
+ let tunnel;
2233
+ try {
2234
+ tunnel = await this.connectUpstream(adjudicated, target.port ?? 443);
2235
+ } catch (error) {
2236
+ this.options.logger.warn(`permission-rules: upstream connect failed for ${target.host}: ${String(error)}`);
2237
+ socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
2238
+ return;
2239
+ }
2197
2240
  tunnelHolder.tunnel = tunnel;
2198
2241
  this.sockets.add(socket);
2199
2242
  this.sockets.add(tunnel);
@@ -2204,27 +2247,10 @@ var NetworkProxy = class {
2204
2247
  socket.on("close", cleanup);
2205
2248
  tunnel.on("close", cleanup);
2206
2249
  tunnel.on("error", () => socket.destroy());
2207
- tunnel.once("connect", () => {
2208
- socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
2209
- if (head.length > 0) tunnel.write(head);
2210
- tunnel.pipe(socket);
2211
- socket.pipe(tunnel);
2212
- });
2213
- }
2214
- /** Adjudicate one plain-HTTP request; deny/ask blocks with a structured 403 before any forwarding. */
2215
- async forwardOrBlock(res, target, forward) {
2216
- const decision = await this.decideWithResolution(target);
2217
- if (decision.action !== "allow") {
2218
- this.recordBlock(decision, target);
2219
- const body = blockMessage(decision);
2220
- res.writeHead(403, {
2221
- "content-type": "text/plain; charset=utf-8",
2222
- "content-length": Buffer.byteLength(body)
2223
- });
2224
- res.end(body);
2225
- return;
2226
- }
2227
- forward();
2250
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
2251
+ if (head.length > 0) tunnel.write(head);
2252
+ tunnel.pipe(socket);
2253
+ socket.pipe(tunnel);
2228
2254
  }
2229
2255
  /** Resolve a hostname once so `ips`-scoped rules see the real addresses, then decide. */
2230
2256
  async decideWithResolution(target) {
@@ -2233,12 +2259,21 @@ var NetworkProxy = class {
2233
2259
  all: true,
2234
2260
  verbatim: true
2235
2261
  })).map((entry) => entry.address);
2236
- if (resolved.length > 0) return this.options.decide({
2237
- ...target,
2238
- ips: [...target.ips, ...resolved]
2239
- });
2262
+ if (resolved.length > 0) {
2263
+ const effective = {
2264
+ ...target,
2265
+ ips: [...target.ips, ...resolved]
2266
+ };
2267
+ return {
2268
+ decision: await this.options.decide(effective),
2269
+ target: effective
2270
+ };
2271
+ }
2240
2272
  } catch {}
2241
- return this.options.decide(target);
2273
+ return {
2274
+ decision: await this.options.decide(target),
2275
+ target
2276
+ };
2242
2277
  }
2243
2278
  /** Record a block: counters, recent ring, and the runtime hook (logger + session audit). */
2244
2279
  recordBlock(decision, target) {
@@ -2285,6 +2320,33 @@ function connectTarget(authority) {
2285
2320
  };
2286
2321
  }
2287
2322
  /**
2323
+ * The literal addresses a connection may use — everything the decision was
2324
+ * made on. Returning them keeps the connection on an adjudicated address
2325
+ * instead of letting a second DNS lookup pick a different one.
2326
+ * @param target - the adjudicated target.
2327
+ * @returns the adjudicated literal addresses, in decision order.
2328
+ */
2329
+ function pinnedAddresses(target) {
2330
+ return target.ips.filter((candidate) => isIpLiteral(candidate));
2331
+ }
2332
+ /**
2333
+ * A `lookup` that answers from the adjudicated address list without a second
2334
+ * DNS query. Node's happy-eyeballs path asks with `all: true` and tries the
2335
+ * returned addresses in order, so a multi-address host keeps its fallbacks.
2336
+ * @param addresses - the adjudicated literal addresses.
2337
+ * @returns the lookup function handed to `http(s).request`.
2338
+ */
2339
+ function pinnedLookup(addresses) {
2340
+ const resolved = addresses.map((address) => ({
2341
+ address,
2342
+ family: address.includes(":") ? 6 : 4
2343
+ }));
2344
+ return (_hostname, options, callback) => {
2345
+ if (options.all === true) callback(null, resolved);
2346
+ else callback(null, resolved[0].address, resolved[0].family);
2347
+ };
2348
+ }
2349
+ /**
2288
2350
  * Inject the proxy environment variables for subprocesses and return a
2289
2351
  * disposer restoring every previous value exactly (so updates and
2290
2352
  * uninstalls leave the host environment untouched). `noProxy: 'clear'`
@@ -99,10 +99,15 @@ export declare class NetworkProxy {
99
99
  close(): Promise<void>;
100
100
  /** Plain-HTTP proxying: absolute-form requests are adjudicated and forwarded (or blocked with a structured 403). */
101
101
  private handleRequest;
102
+ /**
103
+ * Connect to the first adjudicated address that accepts, so the tunnel lands
104
+ * on an address the decision approved; a hostname target with no resolution
105
+ * keeps the historical hostname connect. Each candidate is tried in order so
106
+ * a multi-address host does not lose its fallback.
107
+ */
108
+ private connectUpstream;
102
109
  /** CONNECT tunneling (HTTPS and friends): adjudicate, then either 403 or an established TCP tunnel. */
103
110
  private handleConnect;
104
- /** Adjudicate one plain-HTTP request; deny/ask blocks with a structured 403 before any forwarding. */
105
- private forwardOrBlock;
106
111
  /** Resolve a hostname once so `ips`-scoped rules see the real addresses, then decide. */
107
112
  private decideWithResolution;
108
113
  /** Record a block: counters, recent ring, and the runtime hook (logger + session audit). */
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/proxy.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/proxy.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-permission-rules",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "description": "Declarative Claude Code-style permission rules plus a Codex-style process-level network policy for DeepSeek Harness: ordered allow/deny/ask rules with tool-name, argument (glob/regex), workspace-path, and network-target (domain/ip/port/scheme) matching on the tools/pre-execute waterfall, a built-in local HTTP/CONNECT proxy governing shell subprocess traffic, three network modes mapped onto the official sandbox presets, full session-log audit, HMR rule reload, and a settings page with a rule editor, block counters, and recent interception lists.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -76,7 +76,7 @@
76
76
  "network:outbound"
77
77
  ],
78
78
  "compatibility": {
79
- "dshVersions": ["0.1.2-rc.1","0.1.5-alpha.1"]
79
+ "dshVersions": ["0.1.2-rc.1","0.1.5-rc.1"]
80
80
  },
81
81
  "capability": {
82
82
  "id": "rules",
@@ -154,31 +154,31 @@
154
154
  "zod": "^4.4.3"
155
155
  },
156
156
  "devDependencies": {
157
- "@deepseek-ai/dsh-attachment": "0.1.5-alpha.1",
157
+ "@deepseek-ai/dsh-attachment": "0.1.5-rc.1",
158
158
  "@deepseek-ai/cordis": "^4.0.2",
159
159
  "@deepseek-ai/cordis-plugin-include": "^1.0.7",
160
160
  "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
161
- "@deepseek-ai/dsh-agent": "0.1.5-alpha.1",
162
- "@deepseek-ai/dsh-brand": "0.1.5-alpha.1",
163
- "@deepseek-ai/dsh-client-connection": "0.1.5-alpha.1",
164
- "@deepseek-ai/dsh-client-locale": "0.1.5-alpha.1",
165
- "@deepseek-ai/dsh-client-ui-settings": "0.1.5-alpha.1",
166
- "@deepseek-ai/dsh-client-ui-slots": "0.1.5-alpha.1",
167
- "@deepseek-ai/dsh-client-web": "0.1.5-alpha.1",
168
- "@deepseek-ai/dsh-commands": "0.1.5-alpha.1",
169
- "@deepseek-ai/dsh-invariants": "0.1.5-alpha.1",
170
- "@deepseek-ai/dsh-llm": "0.1.5-alpha.1",
171
- "@deepseek-ai/dsh-session": "0.1.5-alpha.1",
172
- "@deepseek-ai/dsh-session-projection": "0.1.5-alpha.1",
173
- "@deepseek-ai/dsh-subagent": "0.1.5-alpha.1",
174
- "@deepseek-ai/dsh-tools": "0.1.5-alpha.1",
175
- "@deepseek-ai/dsh-typert-protocol": "0.1.5-alpha.1",
176
- "@deepseek-ai/dsh-user-approval": "0.1.5-alpha.1",
161
+ "@deepseek-ai/dsh-agent": "0.1.5-rc.1",
162
+ "@deepseek-ai/dsh-brand": "0.1.5-rc.1",
163
+ "@deepseek-ai/dsh-client-connection": "0.1.5-rc.1",
164
+ "@deepseek-ai/dsh-client-locale": "0.1.5-rc.1",
165
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.1",
166
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.1",
167
+ "@deepseek-ai/dsh-client-web": "0.1.5-rc.1",
168
+ "@deepseek-ai/dsh-commands": "0.1.5-rc.1",
169
+ "@deepseek-ai/dsh-invariants": "0.1.5-rc.1",
170
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.1",
171
+ "@deepseek-ai/dsh-session": "0.1.5-rc.1",
172
+ "@deepseek-ai/dsh-session-projection": "0.1.5-rc.1",
173
+ "@deepseek-ai/dsh-subagent": "0.1.5-rc.1",
174
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.1",
175
+ "@deepseek-ai/dsh-typert-protocol": "0.1.5-rc.1",
176
+ "@deepseek-ai/dsh-user-approval": "0.1.5-rc.1",
177
177
  "@deepseek-ai/schemastery": "^3.18.2",
178
178
  "@eslint/js": "^10.0.1",
179
179
  "@types/node": "^22.19.0",
180
180
  "@vitest/coverage-v8": "^3.2.0",
181
- "dsh-auto-review": "file:vendor/dsh-auto-review-0.12.1.tgz",
181
+ "dsh-auto-review": "file:vendor/dsh-auto-review-0.12.2.tgz",
182
182
  "eslint": "^10.8.1",
183
183
  "globals": "^17.11.0",
184
184
  "react": "18.3.1",
@@ -82,13 +82,14 @@ const STORAGE_ROW_TYPES = new Set([
82
82
  /**
83
83
  * The harness build's generated event vocabulary
84
84
  * (`KNOWN_SESSION_EVENT_TYPES`, `packages/core/session/src/known-event-types.ts`,
85
- * synced with the `0.1.5-alpha.1` catalog), plus the v1/v2-only spellings the
85
+ * synced with the `0.1.5-rc.1` catalog), plus the v1/v2-only spellings the
86
86
  * older generations wrote (`assistant/chunk`, `tool/code-dispatch`,
87
87
  * `tool/code-dispatch-start`; V3 renamed the latter two to `tool/ptc-dispatch`
88
88
  * and `tool/ptc-dispatch-start`), so every generation scans clean. Rows whose
89
89
  * type is outside this set are what the read path refuses when the envelope
90
90
  * lacks `ignorable: true`; the scan reports them so nothing is missed.
91
- * Regenerate this list from the harness checkout when it changes.
91
+ * Regenerate this list from the harness checkout when it changes (0.1.5-rc.1
92
+ * added `deliverables/presented` and `subagent/catalog`).
92
93
  */
93
94
  const KNOWN_SESSION_EVENT_TYPES = new Set([
94
95
  'agent-preset/selected',
@@ -107,6 +108,7 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
107
108
  'compaction/prune',
108
109
  'compaction/start',
109
110
  'compaction/summary',
111
+ 'deliverables/presented',
110
112
  'feedback/message-delete',
111
113
  'feedback/message-put',
112
114
  'feedback/record',
@@ -128,6 +130,7 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
128
130
  'session/title-llm-request',
129
131
  'step/end',
130
132
  'step/start',
133
+ 'subagent/catalog',
131
134
  'subagent/descriptor',
132
135
  'subagent/model-selection-policy',
133
136
  'system/message',
package/src/proxy.ts CHANGED
@@ -21,7 +21,7 @@ import { connect } from 'node:net'
21
21
  import { lookup } from 'node:dns/promises'
22
22
  import type { AddressInfo } from 'node:net'
23
23
  import type { Duplex } from 'node:stream'
24
- import type { Server, IncomingMessage, ServerResponse } from 'node:http'
24
+ import type { Server, IncomingMessage, RequestOptions, ServerResponse } from 'node:http'
25
25
  import type { CallId } from './call-id.ts'
26
26
  import type { NetworkDecision, NetworkMode } from './network.ts'
27
27
  import { blockMessage } from './network.ts'
@@ -163,23 +163,65 @@ export class NetworkProxy {
163
163
  res.end('permission-rules proxy: only absolute-form proxy requests are served here\n')
164
164
  return
165
165
  }
166
- await this.forwardOrBlock(res, target, () => {
167
- const upstream = new URL(req.url as string)
168
- const send = upstream.protocol === 'https:' ? httpsRequest : httpRequest
169
- const proxyReq = send(upstream, { method: req.method, headers: req.headers }, proxyRes => {
170
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
171
- proxyRes.pipe(res)
172
- })
173
- proxyReq.on('error', (error: unknown) => {
174
- if (!res.headersSent) {
175
- res.writeHead(502, { 'content-type': 'text/plain' })
176
- res.end(`[network: upstream error] ${String(error)}\n`)
177
- } else {
178
- res.destroy()
179
- }
180
- })
181
- req.pipe(proxyReq)
166
+ const { decision, target: adjudicated } = await this.decideWithResolution(target)
167
+ if (decision.action !== 'allow') {
168
+ this.recordBlock(decision, target)
169
+ const body = blockMessage(decision)
170
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(body) })
171
+ res.end(body)
172
+ return
173
+ }
174
+ const upstream = new URL(req.url as string)
175
+ const send = upstream.protocol === 'https:' ? httpsRequest : httpRequest
176
+ // The forwarded connection is pinned to the addresses the decision was made
177
+ // on, so a hostname that re-resolves between adjudication and connect
178
+ // (DNS rebinding) cannot reach an address the rules never approved.
179
+ const pinned = pinnedAddresses(adjudicated)
180
+ const options: RequestOptions = {
181
+ method: req.method,
182
+ headers: req.headers,
183
+ ...(pinned.length === 0 ? {} : { lookup: pinnedLookup(pinned) }),
184
+ }
185
+ const proxyReq = send(upstream, options, proxyRes => {
186
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
187
+ proxyRes.pipe(res)
188
+ })
189
+ proxyReq.on('error', (error: unknown) => {
190
+ if (!res.headersSent) {
191
+ res.writeHead(502, { 'content-type': 'text/plain' })
192
+ res.end(`[network: upstream error] ${String(error)}\n`)
193
+ } else {
194
+ res.destroy()
195
+ }
182
196
  })
197
+ req.pipe(proxyReq)
198
+ }
199
+
200
+ /**
201
+ * Connect to the first adjudicated address that accepts, so the tunnel lands
202
+ * on an address the decision approved; a hostname target with no resolution
203
+ * keeps the historical hostname connect. Each candidate is tried in order so
204
+ * a multi-address host does not lose its fallback.
205
+ */
206
+ private async connectUpstream(target: NetworkTarget, port: number): Promise<Duplex> {
207
+ const pinned = pinnedAddresses(target)
208
+ if (pinned.length === 0) return connect(port, target.host)
209
+ let lastError: unknown
210
+ for (const address of pinned) {
211
+ try {
212
+ return await new Promise<Duplex>((resolve, reject) => {
213
+ const socket = connect(port, address)
214
+ socket.once('connect', () => resolve(socket))
215
+ socket.once('error', (error: unknown) => {
216
+ socket.destroy()
217
+ reject(error instanceof Error ? error : new Error(String(error)))
218
+ })
219
+ })
220
+ } catch (error) {
221
+ lastError = error
222
+ }
223
+ }
224
+ throw lastError instanceof Error ? lastError : new Error('no adjudicated address accepted the connection')
183
225
  }
184
226
 
185
227
  /** CONNECT tunneling (HTTPS and friends): adjudicate, then either 403 or an established TCP tunnel. */
@@ -201,14 +243,21 @@ export class NetworkProxy {
201
243
  socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
202
244
  return
203
245
  }
204
- const decision = await this.decideWithResolution(target)
246
+ const { decision, target: adjudicated } = await this.decideWithResolution(target)
205
247
  if (decision.action !== 'allow') {
206
248
  this.recordBlock(decision, target)
207
249
  const body = blockMessage(decision)
208
250
  socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
209
251
  return
210
252
  }
211
- const tunnel = connect(target.port ?? 443, target.host)
253
+ let tunnel: Duplex
254
+ try {
255
+ tunnel = await this.connectUpstream(adjudicated, target.port ?? 443)
256
+ } catch (error) {
257
+ this.options.logger.warn(`permission-rules: upstream connect failed for ${target.host}: ${String(error)}`)
258
+ socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
259
+ return
260
+ }
212
261
  tunnelHolder.tunnel = tunnel
213
262
  this.sockets.add(socket)
214
263
  this.sockets.add(tunnel)
@@ -219,39 +268,27 @@ export class NetworkProxy {
219
268
  socket.on('close', cleanup)
220
269
  tunnel.on('close', cleanup)
221
270
  tunnel.on('error', () => socket.destroy())
222
- tunnel.once('connect', () => {
223
- socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
224
- if (head.length > 0) tunnel.write(head)
225
- tunnel.pipe(socket)
226
- socket.pipe(tunnel)
227
- })
228
- }
229
-
230
- /** Adjudicate one plain-HTTP request; deny/ask blocks with a structured 403 before any forwarding. */
231
- private async forwardOrBlock(res: ServerResponse, target: NetworkTarget, forward: () => void): Promise<void> {
232
- const decision = await this.decideWithResolution(target)
233
- if (decision.action !== 'allow') {
234
- this.recordBlock(decision, target)
235
- const body = blockMessage(decision)
236
- res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(body) })
237
- res.end(body)
238
- return
239
- }
240
- forward()
271
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
272
+ if (head.length > 0) tunnel.write(head)
273
+ tunnel.pipe(socket)
274
+ socket.pipe(tunnel)
241
275
  }
242
276
 
243
277
  /** Resolve a hostname once so `ips`-scoped rules see the real addresses, then decide. */
244
- private async decideWithResolution(target: NetworkTarget): Promise<NetworkDecision> {
278
+ private async decideWithResolution(target: NetworkTarget): Promise<{ decision: NetworkDecision; target: NetworkTarget }> {
245
279
  if (!isIpLiteral(target.host)) {
246
280
  try {
247
281
  const addresses = await lookup(target.host, { all: true, verbatim: true })
248
282
  const resolved = addresses.map(entry => entry.address)
249
- if (resolved.length > 0) return this.options.decide({ ...target, ips: [...target.ips, ...resolved] })
283
+ if (resolved.length > 0) {
284
+ const effective: NetworkTarget = { ...target, ips: [...target.ips, ...resolved] }
285
+ return { decision: await this.options.decide(effective), target: effective }
286
+ }
250
287
  } catch {
251
288
  // Unresolvable target: decide on the literal name (ip-scoped rules simply cannot fire).
252
289
  }
253
290
  }
254
- return this.options.decide(target)
291
+ return { decision: await this.options.decide(target), target }
255
292
  }
256
293
 
257
294
  /** Record a block: counters, recent ring, and the runtime hook (logger + session audit). */
@@ -295,6 +332,32 @@ function connectTarget(authority: string): NetworkTarget | undefined {
295
332
  return { scheme: 'https', host, port, ips: isIpLiteral(host) ? [host] : [] }
296
333
  }
297
334
 
335
+ /**
336
+ * The literal addresses a connection may use — everything the decision was
337
+ * made on. Returning them keeps the connection on an adjudicated address
338
+ * instead of letting a second DNS lookup pick a different one.
339
+ * @param target - the adjudicated target.
340
+ * @returns the adjudicated literal addresses, in decision order.
341
+ */
342
+ function pinnedAddresses(target: NetworkTarget): readonly string[] {
343
+ return target.ips.filter(candidate => isIpLiteral(candidate))
344
+ }
345
+
346
+ /**
347
+ * A `lookup` that answers from the adjudicated address list without a second
348
+ * DNS query. Node's happy-eyeballs path asks with `all: true` and tries the
349
+ * returned addresses in order, so a multi-address host keeps its fallbacks.
350
+ * @param addresses - the adjudicated literal addresses.
351
+ * @returns the lookup function handed to `http(s).request`.
352
+ */
353
+ function pinnedLookup(addresses: readonly string[]): NonNullable<RequestOptions['lookup']> {
354
+ const resolved = addresses.map(address => ({ address, family: address.includes(':') ? 6 : 4 }))
355
+ return (_hostname, options, callback) => {
356
+ if (options.all === true) callback(null, resolved)
357
+ else callback(null, resolved[0]!.address, resolved[0]!.family)
358
+ }
359
+ }
360
+
298
361
  /**
299
362
  * Inject the proxy environment variables for subprocesses and return a
300
363
  * disposer restoring every previous value exactly (so updates and