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/src/config.ts CHANGED
@@ -64,8 +64,29 @@ export interface NetworkConfig {
64
64
  injectEnv?: boolean
65
65
  /** Subprocess NO_PROXY handling: `clear` enforces the policy (default) or `preserve` keeps ambient values. */
66
66
  noProxy?: NoProxyPolicy
67
+ /**
68
+ * Upstream proxy for connections this proxy ALLOWS: `off` (default — dial
69
+ * directly), `inherit` (reuse the proxy names from the launch environment),
70
+ * or an explicit `http(s)://` proxy URL. A blocked connection never reaches
71
+ * it, and `ips`-scoped decisions plus loopback targets are never chained —
72
+ * see the network section of the READMEs.
73
+ */
74
+ upstreamProxy?: UpstreamProxySetting
75
+ /**
76
+ * Whether the settings page may run its "allow this host" action, which
77
+ * writes one minimal `domains` allow rule into the nearest effective rule
78
+ * file (`true` by default). `false` hides the button and makes the
79
+ * `permissionRules/allowHost` RPC refuse: the page's rule EDITOR is
80
+ * strictly wider (it writes arbitrary rule text) and is not affected, so
81
+ * this is a narrow safety switch for deployments that want rule changes to
82
+ * stay a deliberate, hand-written act.
83
+ */
84
+ allowHostAction?: boolean
67
85
  }
68
86
 
87
+ /** Upstream chaining setting: `off`, `inherit`, or an explicit http(s) proxy URL. */
88
+ export type UpstreamProxySetting = 'off' | 'inherit' | string
89
+
69
90
  /** Raw plugin config — every field optional; {@link Config} supplies the defaults. */
70
91
  export interface Config {
71
92
  /**
@@ -142,6 +163,8 @@ export interface ResolvedNetworkConfig {
142
163
  readonly loopback: LoopbackPolicy
143
164
  readonly injectEnv: boolean
144
165
  readonly noProxy: NoProxyPolicy
166
+ readonly upstreamProxy: UpstreamProxySetting
167
+ readonly allowHostAction: boolean
145
168
  }
146
169
 
147
170
  /** Builtin baseline after {@link resolveConfig}: the switch plus the resolved absolute path. */
@@ -200,6 +223,8 @@ export const Config: z<Config> = z.object({
200
223
  loopback: z.union(['allow', 'policy'] as const).default('allow'),
201
224
  injectEnv: z.boolean().default(true),
202
225
  noProxy: z.union(['clear', 'preserve'] as const).default('clear'),
226
+ upstreamProxy: z.string().default('off'),
227
+ allowHostAction: z.boolean().default(true),
203
228
  }),
204
229
  builtin: z.object({
205
230
  enabled: z.boolean().default(true),
@@ -292,6 +317,7 @@ function resolveNetworkConfig(raw: NetworkConfig | undefined): ResolvedNetworkCo
292
317
  assertEnum('network.noProxy', noProxy, ['clear', 'preserve'])
293
318
  assertBoolean('network.enabled', raw?.enabled ?? true)
294
319
  assertBoolean('network.injectEnv', raw?.injectEnv ?? true)
320
+ assertBoolean('network.allowHostAction', raw?.allowHostAction ?? true)
295
321
  const proxyBind = raw?.proxyBind ?? '127.0.0.1'
296
322
  if (typeof proxyBind !== 'string' || proxyBind.trim().length === 0) {
297
323
  throw new TypeError(`network.proxyBind must be a non-empty string, got ${typeof proxyBind}`)
@@ -304,6 +330,8 @@ function resolveNetworkConfig(raw: NetworkConfig | undefined): ResolvedNetworkCo
304
330
  if (!Number.isSafeInteger(proxyMaxRecent) || proxyMaxRecent <= 0) {
305
331
  throw new TypeError(`network.proxyMaxRecent must be a positive safe integer, got ${String(raw?.proxyMaxRecent)}`)
306
332
  }
333
+ const upstreamProxy = raw?.upstreamProxy ?? 'off'
334
+ assertUpstreamProxy(upstreamProxy)
307
335
  return {
308
336
  enabled: raw?.enabled ?? true,
309
337
  mode: mode as 'auto' | NetworkMode,
@@ -315,7 +343,35 @@ function resolveNetworkConfig(raw: NetworkConfig | undefined): ResolvedNetworkCo
315
343
  loopback,
316
344
  injectEnv: raw?.injectEnv ?? true,
317
345
  noProxy,
346
+ upstreamProxy,
347
+ allowHostAction: raw?.allowHostAction ?? true,
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Throw unless `value` is `off`, `inherit`, or a usable http(s) proxy URL.
353
+ *
354
+ * Node has no SOCKS client, and the harness's own policy refuses a proxy URL it
355
+ * cannot express for a scheme, so a SOCKS value is rejected at the mount with an
356
+ * actionable message instead of failing at connect time. `''` is rejected too:
357
+ * an empty string reads as "no upstream" but silently disables chaining.
358
+ */
359
+ function assertUpstreamProxy(value: string): void {
360
+ const invalid = (detail: string): TypeError =>
361
+ new TypeError(`network.upstreamProxy must be "off" | "inherit" | an http(s) proxy URL, got ${JSON.stringify(value)}${detail}`)
362
+ if (value === 'off' || value === 'inherit') return
363
+ if (typeof value !== 'string' || value.trim().length === 0) throw invalid('')
364
+ let parsed: URL | undefined
365
+ try {
366
+ parsed = new URL(value)
367
+ } catch {
368
+ parsed = undefined
369
+ }
370
+ if (parsed === undefined) throw invalid('')
371
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
372
+ throw invalid(` (${parsed.protocol}// is not supported: Node has no SOCKS client, so give an http:// or https:// proxy)`)
318
373
  }
374
+ if (parsed.hostname.length === 0) throw invalid(' (it must name a host)')
319
375
  }
320
376
 
321
377
  /** Throw unless `key` is one of `allowed` (TypeScript's closed enums are not runtime checks). */
package/src/prose.ts CHANGED
@@ -64,6 +64,7 @@ export interface UiProse {
64
64
  networkHeader: (mode: string, sandboxMode: string | undefined, configuredMode: string, proxyActive: boolean, proxyPort: number) => string
65
65
  /** Cumulative block counters line. */
66
66
  networkCounters: (denied: number, askBlocked: number) => string
67
+ networkUpstream: (mode: 'off' | 'inherit' | 'url', http: string | null, https: string | null, active: boolean, chained: number) => string
67
68
  /** Line shown when no proxy blocks were recorded yet. */
68
69
  noNetworkBlocks: string
69
70
  /** One recent proxy-block row. */
@@ -129,6 +130,7 @@ const EN: UiProse = {
129
130
  networkDisabled: 'Network policy disabled (network.enabled: false): no proxy, no web-tool mode defaults.',
130
131
  networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Network policy: mode ${mode}${sandboxMode === undefined ? '' : ` (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)'}.`,
131
132
  networkCounters: (denied, askBlocked) => `Blocks: ${denied} denied, ${askBlocked} ask-blocked.`,
133
+ networkUpstream: (mode, http, https, active, chained) => `Upstream: ${mode}${http === null ? '' : ` http ${http}`}${https === null ? '' : ` https ${https}`}${active ? '' : ' (inactive)'}, ${chained} chained.`,
132
134
  noNetworkBlocks: 'No network blocks recorded yet.',
133
135
  networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
134
136
  const when = new Date(time).toISOString()
@@ -172,6 +174,7 @@ const ZH: UiProse = {
172
174
  networkDisabled: '网络策略已停用(network.enabled: false):无代理、无 web 工具模式默认裁决。',
173
175
  networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `网络策略:模式 ${mode}${sandboxMode === undefined ? '' : `(沙箱预设 ${sandboxMode})`}${configuredMode === 'auto' ? '' : `(显式配置 ${configuredMode})`};代理${proxyActive ? `运行于 127.0.0.1:${proxyPort}` : '未激活(绑定失败——shell 网络策略未生效)'}。`,
174
176
  networkCounters: (denied, askBlocked) => `拦截:拒绝 ${denied} 次,待审批阻断 ${askBlocked} 次。`,
177
+ networkUpstream: (mode, http, https, active, chained) => `上游:${mode}${http === null ? '' : ` http ${http}`}${https === null ? '' : ` https ${https}`}${active ? '' : '(未生效)'},已链式 ${chained} 条。`,
175
178
  noNetworkBlocks: '尚未记录任何网络拦截。',
176
179
  networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
177
180
  const when = new Date(time).toISOString()
@@ -215,6 +218,7 @@ const ES: UiProse = {
215
218
  networkDisabled: 'Política de red desactivada (network.enabled: false): sin proxy, sin valores predeterminados de modo para herramientas web.',
216
219
  networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Política de red: modo ${mode}${sandboxMode === undefined ? '' : ` (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)'}.`,
217
220
  networkCounters: (denied, askBlocked) => `Bloqueos: ${denied} denegados, ${askBlocked} bloqueados por aprobación.`,
221
+ networkUpstream: (mode, http, https, active, chained) => `Upstream: ${mode}${http === null ? '' : ` http ${http}`}${https === null ? '' : ` https ${https}`}${active ? '' : ' (inactivo)'}, ${chained} encadenadas.`,
218
222
  noNetworkBlocks: 'Aún no se han registrado bloqueos de red.',
219
223
  networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
220
224
  const when = new Date(time).toISOString()
@@ -258,6 +262,7 @@ const PT: UiProse = {
258
262
  networkDisabled: 'Política de rede desativada (network.enabled: false): sem proxy, sem padrões de modo para ferramentas web.',
259
263
  networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `Política de rede: modo ${mode}${sandboxMode === undefined ? '' : ` (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)'}.`,
260
264
  networkCounters: (denied, askBlocked) => `Bloqueios: ${denied} negados, ${askBlocked} bloqueados por aprovação.`,
265
+ networkUpstream: (mode, http, https, active, chained) => `Upstream: ${mode}${http === null ? '' : ` http ${http}`}${https === null ? '' : ` https ${https}`}${active ? '' : ' (inativo)'}, ${chained} encadeadas.`,
261
266
  noNetworkBlocks: 'Nenhum bloqueio de rede registrado ainda.',
262
267
  networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
263
268
  const when = new Date(time).toISOString()
@@ -301,6 +306,7 @@ const HI: UiProse = {
301
306
  networkDisabled: 'नेटवर्क नीति अक्षम (network.enabled: false): कोई प्रॉक्सी नहीं, कोई वेब-टूल मोड डिफ़ॉल्ट नहीं।',
302
307
  networkHeader: (mode, sandboxMode, configuredMode, proxyActive, proxyPort) => `नेटवर्क नीति: मोड ${mode}${sandboxMode === undefined ? '' : ` (सैंडबॉक्स प्रीसेट ${sandboxMode})`}${configuredMode === 'auto' ? '' : ` (कॉन्फ़िगर किया ${configuredMode})`}; प्रॉक्सी ${proxyActive ? `सक्रिय 127.0.0.1:${proxyPort} पर` : 'निष्क्रिय (बाइंड विफल — शेल नेटवर्क नीति लागू नहीं)'}।`,
303
308
  networkCounters: (denied, askBlocked) => `ब्लॉक: ${denied} अस्वीकृत, ${askBlocked} अनुमोदन-ब्लॉक।`,
309
+ networkUpstream: (mode, http, https, active, chained) => `Upstream: ${mode}${http === null ? '' : ` http ${http}`}${https === null ? '' : ` https ${https}`}${active ? '' : ' (निष्क्रिय)'}, ${chained} श्रृंखलित।`,
304
310
  noNetworkBlocks: 'अभी तक कोई नेटवर्क ब्लॉक दर्ज नहीं हुआ।',
305
311
  networkBlockLine: (time, tool, attributed, domain, scheme, port, action, matched, ruleIndex, reason) => {
306
312
  const when = new Date(time).toISOString()
package/src/proxy.ts CHANGED
@@ -24,7 +24,7 @@ import type { Duplex } from 'node:stream'
24
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
- import { blockMessage } from './network.ts'
27
+ import { blockMessage, isLoopbackTarget } from './network.ts'
28
28
  import { isIpLiteral, parseUrlTarget } from './rules.ts'
29
29
  import type { NetworkTarget, SchemeName } from './rules.ts'
30
30
 
@@ -34,6 +34,73 @@ export const PROXY_ENV_NAMES: readonly string[] = ['HTTP_PROXY', 'HTTPS_PROXY',
34
34
  /** NO_PROXY names cleared (or preserved) by the injector. */
35
35
  export const NO_PROXY_ENV_NAMES: readonly string[] = ['NO_PROXY', 'no_proxy']
36
36
 
37
+ /** How long a chained CONNECT waits for the upstream to answer before it gives up. */
38
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 10_000
39
+
40
+ /** The upstream proxies the launch environment supplies, resolved per scheme. */
41
+ export interface AmbientUpstream {
42
+ readonly http?: string
43
+ readonly https?: string
44
+ }
45
+
46
+ /**
47
+ * Resolve ambient upstream candidates the way the harness's own proxy policy
48
+ * does: the scheme-specific name (lowercase first, blank treated as unset),
49
+ * then `ALL_PROXY`, then — for https only — the http name. A value that is not
50
+ * a parseable `http(s)://` URL is dropped here; the runtime reports those once.
51
+ * @param lookup - reads one environment name (the launch snapshot, or `process.env`).
52
+ * @returns the usable candidates, each absent when it is unusable.
53
+ */
54
+ export function readAmbientProxy(lookup: (name: string) => string | undefined): AmbientUpstream {
55
+ const pick = (names: readonly string[]): string | undefined => {
56
+ for (const name of names) {
57
+ const value = lookup(name)
58
+ if (value !== undefined && value.trim().length > 0) return value.trim()
59
+ }
60
+ return undefined
61
+ }
62
+ const usable = (value: string | undefined): string | undefined => {
63
+ if (value === undefined) return undefined
64
+ try {
65
+ const parsed = new URL(value)
66
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? value : undefined
67
+ } catch {
68
+ return undefined
69
+ }
70
+ }
71
+ const all = usable(pick(['ALL_PROXY', 'all_proxy']))
72
+ const http = usable(pick(['http_proxy', 'HTTP_PROXY'])) ?? all
73
+ const https = usable(pick(['https_proxy', 'HTTPS_PROXY'])) ?? all ?? http
74
+ return { ...(http === undefined ? {} : { http }), ...(https === undefined ? {} : { https }) }
75
+ }
76
+
77
+ /**
78
+ * The loggable form of a proxy URL: the password is replaced, everything else
79
+ * is kept verbatim. An upstream URL may carry credentials, and a proxy URL is
80
+ * printed in warnings, `/rules network` and the settings snapshot, so it must
81
+ * never be emitted raw.
82
+ * @param value - the configured upstream URL.
83
+ * @returns the same URL with its password masked, or the input when unparseable.
84
+ */
85
+ export function redactProxyUrl(value: string): string {
86
+ try {
87
+ const parsed = new URL(value)
88
+ if (parsed.username === '' && parsed.password === '') return value
89
+ const user = parsed.username
90
+ const auth = parsed.password === '' ? `${user}@` : `${user}:***@`
91
+ return `${parsed.protocol}//${auth}${parsed.host}`
92
+ } catch {
93
+ return value
94
+ }
95
+ }
96
+
97
+ /** The `Proxy-Authorization` value for an upstream URL carrying userinfo. */
98
+ function proxyAuthorization(url: URL): string | undefined {
99
+ if (url.username === '' && url.password === '') return undefined
100
+ const credentials = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`
101
+ return `Basic ${Buffer.from(credentials).toString('base64')}`
102
+ }
103
+
37
104
  /** One recorded proxy-layer block (settings-page list + session audit + logger). */
38
105
  export interface NetworkBlockRecord {
39
106
  readonly time: number
@@ -49,6 +116,13 @@ export interface NetworkBlockRecord {
49
116
  readonly source: string
50
117
  readonly ruleIndex?: number
51
118
  readonly reason?: string
119
+ /**
120
+ * The attributed agent's workspace root, when the block could be attributed
121
+ * to a session at all. The settings page needs it to name the rule file an
122
+ * "allow this host" action would write; an unattributed (host-level) block
123
+ * leaves it unset.
124
+ */
125
+ readonly cwd?: string
52
126
  }
53
127
 
54
128
  /** Cumulative proxy-layer block counters. */
@@ -62,7 +136,11 @@ export interface ProxyAttribution {
62
136
  readonly tool: string
63
137
  readonly callId?: CallId
64
138
  readonly agent?: {
65
- readonly session: { append(type: 'permissionRules/network', data: unknown, options?: { ignorable?: true }): unknown }
139
+ readonly session: {
140
+ /** The session header the block's workspace is read from. */
141
+ readonly header: { readonly cwd?: string }
142
+ append(type: 'permissionRules/network', data: unknown, options?: { ignorable?: true }): unknown
143
+ }
66
144
  }
67
145
  }
68
146
 
@@ -82,6 +160,12 @@ export interface NetworkProxyOptions {
82
160
  readonly onBlock?: (record: NetworkBlockRecord, attribution: ProxyAttribution | undefined) => void
83
161
  /** Logger sink (proxy failures must never crash the host). */
84
162
  readonly logger: { warn(message: string): void }
163
+ /**
164
+ * The upstream proxies an ALLOWED connection may be chained through, or
165
+ * undefined to dial directly. A getter, so a live settings change takes
166
+ * effect on the next connection.
167
+ */
168
+ readonly upstream?: () => AmbientUpstream | undefined
85
169
  }
86
170
 
87
171
  /**
@@ -96,9 +180,15 @@ export class NetworkProxy {
96
180
  private readonly recent: NetworkBlockRecord[] = []
97
181
  private readonly stats: NetworkStats = { denied: 0, askBlocked: 0 }
98
182
  private actualPort = 0
183
+ private chained = 0
99
184
 
100
185
  constructor(private readonly options: NetworkProxyOptions) {}
101
186
 
187
+ /** Connections dispatched through the configured upstream since mount. */
188
+ chainedConnections(): number {
189
+ return this.chained
190
+ }
191
+
102
192
  /** The bound port (valid after {@link start} resolves). */
103
193
  get port(): number {
104
194
  return this.actualPort
@@ -155,6 +245,76 @@ export class NetworkProxy {
155
245
  })
156
246
  }
157
247
 
248
+ /**
249
+ * The upstream one ALLOWED connection may be chained through, or undefined to
250
+ * dial directly. Three vetoes, in order:
251
+ *
252
+ * 1. a loopback target never chains — a proxy outside this host cannot route
253
+ * its loopback, and the harness's own policy bypasses loopback for the same
254
+ * reason;
255
+ * 2. an `ips`-scoped decision never chains — chaining hands the hostname to
256
+ * the upstream, so "the connection lands on an address the rules saw"
257
+ * (the issue #21 invariant) would stop holding exactly where the rules
258
+ * cared about the address;
259
+ * 3. no usable upstream for the target's scheme.
260
+ *
261
+ * A blocked connection never reaches this method: it is called only on the
262
+ * `allow` branch, after the decision is final.
263
+ * @param target - the adjudicated target.
264
+ * @param decision - the decision that allowed it.
265
+ * @returns the upstream URL to chain through, or undefined.
266
+ */
267
+ private upstreamRouteFor(target: NetworkTarget, decision: NetworkDecision): string | undefined {
268
+ const ambient = this.options.upstream?.()
269
+ if (ambient === undefined) return undefined
270
+ if (isLoopbackTarget(target)) return undefined
271
+ const ipsScope = decision.rule?.source.match.network?.ips
272
+ if (ipsScope !== undefined && ipsScope.length > 0) return undefined
273
+ return (target.scheme ?? 'http') === 'https' ? ambient.https : ambient.http
274
+ }
275
+
276
+ /**
277
+ * Open a tunnel by asking the upstream for one, and hand back the socket it
278
+ * established. The upstream's own hostname is resolved by the system: it is
279
+ * operator configuration, not agent input, so it takes no part in rule
280
+ * adjudication and is outside the adjudicated-address invariant.
281
+ * @param upstream - the upstream proxy URL.
282
+ * @param authority - the `host:port` to ask for (IPv6 already bracketed).
283
+ * @returns the established tunnel socket.
284
+ */
285
+ private connectViaUpstream(upstream: string, authority: string): Promise<Duplex> {
286
+ return new Promise((resolve, reject) => {
287
+ const via = new URL(upstream)
288
+ const send = via.protocol === 'https:' ? httpsRequest : httpRequest
289
+ const headers: Record<string, string> = { host: authority }
290
+ const auth = proxyAuthorization(via)
291
+ if (auth !== undefined) headers['proxy-authorization'] = auth
292
+ const req = send({
293
+ host: via.hostname,
294
+ port: via.port === '' ? (via.protocol === 'https:' ? 443 : 80) : Number(via.port),
295
+ method: 'CONNECT',
296
+ path: authority,
297
+ headers,
298
+ })
299
+ req.setTimeout(UPSTREAM_CONNECT_TIMEOUT_MS, () => { req.destroy(new Error('upstream CONNECT timed out')) })
300
+ req.on('connect', (response, socket) => {
301
+ const status = response.statusCode ?? 0
302
+ if (status >= 200 && status < 300) resolve(socket)
303
+ else {
304
+ socket.destroy()
305
+ reject(new Error(`upstream answered ${String(status)}`))
306
+ }
307
+ })
308
+ // A non-2xx CONNECT answer arrives as a plain response, not as 'connect'.
309
+ req.on('response', response => {
310
+ response.resume()
311
+ reject(new Error(`upstream answered ${String(response.statusCode ?? 0)}`))
312
+ })
313
+ req.on('error', reject)
314
+ req.end()
315
+ })
316
+ }
317
+
158
318
  /** Plain-HTTP proxying: absolute-form requests are adjudicated and forwarded (or blocked with a structured 403). */
159
319
  private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
160
320
  const target = parseUrlTarget(req.url ?? '')
@@ -172,20 +332,40 @@ export class NetworkProxy {
172
332
  return
173
333
  }
174
334
  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 => {
335
+ const onResponse = (proxyRes: IncomingMessage): void => {
186
336
  res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
187
337
  proxyRes.pipe(res)
188
- })
338
+ }
339
+ const chained = this.upstreamRouteFor(adjudicated, decision)
340
+ let proxyReq: ReturnType<typeof httpRequest>
341
+ if (chained === undefined) {
342
+ // The forwarded connection is pinned to the addresses the decision was
343
+ // made on, so a hostname that re-resolves between adjudication and
344
+ // connect (DNS rebinding) cannot reach an address the rules never
345
+ // approved.
346
+ const pinned = pinnedAddresses(adjudicated)
347
+ const options: RequestOptions = {
348
+ method: req.method,
349
+ headers: req.headers,
350
+ ...(pinned.length === 0 ? {} : { lookup: pinnedLookup(pinned) }),
351
+ }
352
+ proxyReq = (upstream.protocol === 'https:' ? httpsRequest : httpRequest)(upstream, options, onResponse)
353
+ } else {
354
+ // Chained: the upstream does the resolving, which is exactly why an
355
+ // `ips`-scoped decision never takes this branch (see upstreamRouteFor).
356
+ const via = new URL(chained)
357
+ const headers: Record<string, string | string[] | undefined> = { ...req.headers }
358
+ const auth = proxyAuthorization(via)
359
+ if (auth !== undefined) headers['proxy-authorization'] = auth
360
+ proxyReq = (via.protocol === 'https:' ? httpsRequest : httpRequest)({
361
+ host: via.hostname,
362
+ port: via.port === '' ? (via.protocol === 'https:' ? 443 : 80) : Number(via.port),
363
+ method: req.method,
364
+ path: req.url, // absolute form, which is what a proxy expects
365
+ headers,
366
+ }, onResponse)
367
+ this.chained += 1
368
+ }
189
369
  proxyReq.on('error', (error: unknown) => {
190
370
  if (!res.headersSent) {
191
371
  res.writeHead(502, { 'content-type': 'text/plain' })
@@ -260,10 +440,14 @@ export class NetworkProxy {
260
440
  return
261
441
  }
262
442
  let tunnel: Duplex
443
+ const chained = this.upstreamRouteFor(adjudicated, decision)
263
444
  try {
264
- tunnel = await this.connectUpstream(adjudicated, target.port ?? 443)
445
+ tunnel = chained === undefined
446
+ ? await this.connectUpstream(adjudicated, target.port ?? 443)
447
+ : await this.connectViaUpstream(chained, target.host.includes(':') ? `[${target.host}]:${String(target.port ?? 443)}` : `${target.host}:${String(target.port ?? 443)}`)
448
+ if (chained !== undefined) this.chained += 1
265
449
  } catch (error) {
266
- this.options.logger.warn(`permission-rules: upstream connect failed for ${target.host}: ${String(error)}`)
450
+ this.options.logger.warn(`permission-rules: upstream connect failed for ${target.host}: ${String(error)}${chained === undefined ? '' : ` (via ${redactProxyUrl(chained)})`}`)
267
451
  socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
268
452
  return
269
453
  }
@@ -303,6 +487,7 @@ export class NetworkProxy {
303
487
  /** Record a block: counters, recent ring, and the runtime hook (logger + session audit). */
304
488
  private recordBlock(decision: NetworkDecision, target: NetworkTarget): void {
305
489
  const attribution = this.options.attribution?.()
490
+ const cwd = attribution?.agent?.session.header.cwd
306
491
  const record: NetworkBlockRecord = {
307
492
  time: Date.now(),
308
493
  tool: attribution?.tool ?? 'subprocess',
@@ -317,6 +502,7 @@ export class NetworkProxy {
317
502
  source: decision.source ?? '',
318
503
  ...(decision.ruleIndex !== undefined ? { ruleIndex: decision.ruleIndex } : {}),
319
504
  ...(decision.rule !== undefined ? { reason: decision.rule.reason } : {}),
505
+ ...(cwd !== undefined ? { cwd } : {}),
320
506
  }
321
507
  if (record.action === 'deny') this.stats.denied += 1
322
508
  else this.stats.askBlocked += 1
@@ -12,7 +12,7 @@ import { existsSync } from 'node:fs'
12
12
  import type { Context } from '@deepseek-ai/cordis'
13
13
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
14
14
  import type { PermissionRulesRuntime } from './runtime.ts'
15
- import type { PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from './wire.ts'
15
+ import type { AllowHostRequest, AllowHostResult, PermissionRulesSnapshot, RulesReadResult, RulesReloadResult, RulesSaveResult } from './wire.ts'
16
16
 
17
17
  declare module '@deepseek-ai/cordis' {
18
18
  interface Context {
@@ -69,8 +69,17 @@ export class PermissionRulesRemoteService extends TypertRemoteService {
69
69
  source: block.source,
70
70
  ruleIndex: block.ruleIndex ?? null,
71
71
  reason: block.reason ?? null,
72
+ cwd: block.cwd ?? null,
72
73
  })),
73
74
  sources,
75
+ allowHostAction: snapshot.allowHostAction,
76
+ upstream: {
77
+ mode: snapshot.upstream.mode,
78
+ http: snapshot.upstream.http,
79
+ https: snapshot.upstream.https,
80
+ active: snapshot.upstream.active,
81
+ chained: snapshot.upstream.chained,
82
+ },
74
83
  }
75
84
  }
76
85
 
@@ -104,6 +113,29 @@ export class PermissionRulesRemoteService extends TypertRemoteService {
104
113
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
105
114
  }
106
115
  }
116
+
117
+ /**
118
+ * Write one minimal allow rule for a blocked host into the nearest
119
+ * effective rule file (the settings page's per-block action). The runtime
120
+ * owns the whole decision — which file, index 0, the validation gate, the
121
+ * reload — and never throws; the try/catch is the same containment
122
+ * `reload()` uses so the page always receives a well-formed result.
123
+ */
124
+ allowHost(request: AllowHostRequest): AllowHostResult {
125
+ try {
126
+ return this.config.runtime.allowHost(request)
127
+ } catch (error: unknown) {
128
+ return {
129
+ ok: false,
130
+ path: null,
131
+ created: false,
132
+ reloaded: 0,
133
+ outcome: null,
134
+ alreadyAllowed: false,
135
+ error: error instanceof Error ? error.message : String(error),
136
+ }
137
+ }
138
+ }
107
139
  }
108
140
 
109
141
  export default PermissionRulesRemoteService
package/src/rules.ts CHANGED
@@ -1100,8 +1100,17 @@ function tryParseHttpUrl(text: string): NetworkTarget | undefined {
1100
1100
  return { scheme: url.protocol === 'https:' ? 'https' : 'http', host, port, ips: literalIpOf(host) }
1101
1101
  }
1102
1102
 
1103
- /** Lowercase, strip IPv6 brackets, strip one trailing dot. */
1104
- function normalizeHost(host: string): string {
1103
+ /**
1104
+ * Lowercase, strip IPv6 brackets, strip one trailing dot.
1105
+ *
1106
+ * Exported because the settings-page allow action must normalize a blocked
1107
+ * host EXACTLY the way target parsing does, or a rule generated from a block
1108
+ * record could name a different host than the connection that was blocked
1109
+ * (`Example.COM.` and `example.com` are the same target).
1110
+ * @param host - a host name or IP literal as it arrived on the wire.
1111
+ * @returns the canonical spelling.
1112
+ */
1113
+ export function normalizeHost(host: string): string {
1105
1114
  let out = host.toLowerCase().replace(/^\[|\]$/g, '')
1106
1115
  while (out.endsWith('.')) out = out.slice(0, -1)
1107
1116
  return out