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.
- package/CHANGELOG.md +20 -0
- package/README.es.md +6 -1
- package/README.hi.md +6 -1
- package/README.md +6 -1
- package/README.pt.md +6 -1
- package/README.zh.md +6 -1
- package/docs/rules-format.en.md +2 -1
- package/docs/rules-format.md +2 -1
- package/lib/client.js +6924 -37
- package/lib/client.js.map +1 -1
- package/lib/index.js +671 -28
- package/lib/typert.host.js +74 -3
- package/lib/types/allow-host.d.ts +151 -0
- package/lib/types/allow-host.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote.d.ts +54 -1
- package/lib/types/client/remote.d.ts.map +1 -1
- package/lib/types/client/section.d.ts +3 -1
- package/lib/types/client/section.d.ts.map +1 -1
- package/lib/types/config.d.ts +22 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/prose.d.ts +1 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/proxy.d.ts +73 -0
- package/lib/types/proxy.d.ts.map +1 -1
- package/lib/types/remote-service.d.ts +9 -1
- package/lib/types/remote-service.d.ts.map +1 -1
- package/lib/types/rules.d.ts +11 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +76 -11
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/typert.host.d.ts +50 -0
- package/lib/types/typert.host.d.ts.map +1 -1
- package/lib/types/wire.d.ts +191 -0
- package/lib/types/wire.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/allow-host.ts +307 -0
- package/src/client/index.ts +7 -1
- package/src/client/locales.ts +36 -0
- package/src/client/remote.ts +4 -1
- package/src/client/section.ts +83 -10
- package/src/config.ts +56 -0
- package/src/prose.ts +6 -0
- package/src/proxy.ts +202 -16
- package/src/remote-service.ts +33 -1
- package/src/rules.ts +11 -2
- package/src/runtime.ts +243 -9
- package/src/wire.ts +119 -0
package/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: {
|
|
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
|
|
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 =
|
|
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
|
package/src/remote-service.ts
CHANGED
|
@@ -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
|
-
/**
|
|
1104
|
-
|
|
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
|