dsh-perm-gate 2.0.0 → 2.1.2
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 +111 -0
- package/INSTALL.ja.md +57 -1
- package/INSTALL.ko.md +54 -1
- package/INSTALL.md +53 -1
- package/INSTALL.zh.md +48 -1
- package/README.ja.md +86 -12
- package/README.ko.md +83 -12
- package/README.md +120 -24
- package/README.zh.md +88 -10
- package/cordis.patch.yml +34 -2
- package/dsh.plugin.json +22 -0
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/client.js +114 -0
- package/lib/client.js.map +1 -1
- package/lib/compiler.d.ts +39 -0
- package/lib/compiler.js +106 -0
- package/lib/config.d.ts +95 -2
- package/lib/config.js +28 -3
- package/lib/engine.js +9 -2
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +189 -0
- package/lib/events.d.ts +13 -0
- package/lib/events.js +31 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +235 -6
- package/lib/network-lifecycle.d.ts +107 -0
- package/lib/network-lifecycle.js +180 -0
- package/lib/network.d.ts +109 -0
- package/lib/network.js +196 -0
- package/lib/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.js +25 -3
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +222 -0
- package/lib/rule-dims.d.ts +139 -0
- package/lib/rule-dims.js +194 -0
- package/lib/rule.d.ts +25 -0
- package/lib/rule.js +15 -1
- package/lib/runtime.d.ts +87 -0
- package/lib/runtime.js +327 -2
- package/lib/session-sweep.d.ts +45 -0
- package/lib/session-sweep.js +153 -0
- package/lib/shadow.d.ts +34 -0
- package/lib/shadow.js +143 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +8 -2
- package/scripts/patch-permission-glyph.mjs +209 -0
package/lib/network.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure network-policy orchestration for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the `tools/pre-execute` gate (URL candidate static check) and
|
|
5
|
+
* the local HTTP/CONNECT proxy (real subprocess traffic). Three policy modes
|
|
6
|
+
* mapped onto DSH sandbox presets, first-match evaluation over the loaded
|
|
7
|
+
* CompiledRuleset, and mode-default fallback decision. No I/O, no process
|
|
8
|
+
* state — unit-testable and replayable.
|
|
9
|
+
*
|
|
10
|
+
* Network is a **parallel execution surface** (NOT a P-layer): it shares the
|
|
11
|
+
* same CompiledRuleset / verdict vocabulary as the P0–P4 chain but does not
|
|
12
|
+
* enter the prefix numbering. The proxy layer has no session context, so
|
|
13
|
+
* `ask` decisions degrade to block + audit.
|
|
14
|
+
*/
|
|
15
|
+
import type { CompiledRuleEntry, CompiledRuleset } from './rule.js';
|
|
16
|
+
/** The three network policy modes. */
|
|
17
|
+
export type NetworkMode = 'deny-all' | 'whitelist' | 'allow-all';
|
|
18
|
+
/** Closed mode list for runtime normalization. */
|
|
19
|
+
export declare const NETWORK_MODES: readonly NetworkMode[];
|
|
20
|
+
/** The official file-sandbox presets the mode maps onto. */
|
|
21
|
+
export type SandboxModeName = 'read-only' | 'workspace-write' | 'danger-full-access';
|
|
22
|
+
/** How a whitelist-mode unlisted target is handled. */
|
|
23
|
+
export type UnlistedAction = 'ask' | 'deny';
|
|
24
|
+
/**
|
|
25
|
+
* How traffic with **no in-flight shell attribution** is handled.
|
|
26
|
+
*
|
|
27
|
+
* - `'allow'` (default) — pass through unreviewed. Such traffic is not a shell
|
|
28
|
+
* subprocess: it is DSH's own client (a built-in network tool, the LLM
|
|
29
|
+
* transport). The proxy is a *subprocess* policy surface, so reviewing the
|
|
30
|
+
* host's own traffic risks the host blocking itself — a far worse failure
|
|
31
|
+
* than a missed block. This is what keeps the built-in network tools working.
|
|
32
|
+
* - `'deny'` — review it like any other connection. Stricter, but if a
|
|
33
|
+
* built-in client ever honors the proxy environment (e.g. Node 24+ with
|
|
34
|
+
* `NODE_USE_ENV_PROXY=1`), DSH's own calls would be blocked and the harness
|
|
35
|
+
* would stop working.
|
|
36
|
+
*/
|
|
37
|
+
export type UnattributedAction = 'allow' | 'deny';
|
|
38
|
+
/** A parsed network target for one connection/URL. */
|
|
39
|
+
export interface NetworkTarget {
|
|
40
|
+
readonly host: string;
|
|
41
|
+
readonly port?: number;
|
|
42
|
+
readonly scheme?: string;
|
|
43
|
+
/** Resolved IP addresses (populated after DNS lookup). */
|
|
44
|
+
readonly ips: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
/** One network decision: a rule hit or the mode-default fallback. */
|
|
47
|
+
export interface NetworkDecision {
|
|
48
|
+
readonly action: 'allow' | 'deny' | 'ask';
|
|
49
|
+
/** Whether a rule (true) or the mode default (false) produced the decision. */
|
|
50
|
+
readonly matched: boolean;
|
|
51
|
+
readonly mode: NetworkMode;
|
|
52
|
+
readonly ruleIndex?: number;
|
|
53
|
+
readonly rule?: CompiledRuleEntry;
|
|
54
|
+
/** Source file path for audit attribution. */
|
|
55
|
+
readonly source?: string;
|
|
56
|
+
/** True when the connection carried no shell attribution and was exempted. */
|
|
57
|
+
readonly unattributed?: true;
|
|
58
|
+
}
|
|
59
|
+
/** Proxy-layer evaluation options. */
|
|
60
|
+
export interface NetworkDecisionOptions {
|
|
61
|
+
readonly mode: NetworkMode;
|
|
62
|
+
readonly unlisted: UnlistedAction;
|
|
63
|
+
/** `allow` short-circuits loopback targets before rules; `policy` evaluates them normally. */
|
|
64
|
+
readonly loopback: 'allow' | 'policy';
|
|
65
|
+
/**
|
|
66
|
+
* Whether an in-flight shell execution can be attributed to this
|
|
67
|
+
* connection. `false` means the connection did not come from a subprocess
|
|
68
|
+
* the gate is tracking. Omitted = treat as attributed (pre-change behavior).
|
|
69
|
+
*/
|
|
70
|
+
readonly attributed?: boolean;
|
|
71
|
+
/** Handling for unattributed traffic. Default `'allow'`. */
|
|
72
|
+
readonly unattributed?: UnattributedAction;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Map one official sandbox preset onto its network mode.
|
|
76
|
+
* `read-only` → deny-all, `workspace-write` → whitelist,
|
|
77
|
+
* `danger-full-access` → allow-all. Unknown → fallback.
|
|
78
|
+
*/
|
|
79
|
+
export declare function networkModeForSandbox(sandbox: string | undefined, fallback: NetworkMode): NetworkMode;
|
|
80
|
+
/** The default decision a mode yields for an unmatched target. */
|
|
81
|
+
export declare function defaultDecision(mode: NetworkMode, unlisted: UnlistedAction): NetworkDecision;
|
|
82
|
+
/**
|
|
83
|
+
* Evaluate the network policy for one target against a compiled ruleset.
|
|
84
|
+
* First-match wins across deny → allow → ask partitions (deny-first).
|
|
85
|
+
*
|
|
86
|
+
* Proxy-layer tool attribution: shell subprocess connections carry
|
|
87
|
+
* `bash`/`pwsh` identity; rules scoped to other tools never fire.
|
|
88
|
+
* Agent identity is unknown at the proxy → agent-scoped rules fail-closed.
|
|
89
|
+
*
|
|
90
|
+
* **Unattributed traffic is exempt by default.** A connection that cannot be
|
|
91
|
+
* tied to an in-flight shell execution did not come from a subprocess the gate
|
|
92
|
+
* manages — it is DSH's own client. Reviewing it would let the host block
|
|
93
|
+
* itself, so it passes through (see {@link UnattributedAction}).
|
|
94
|
+
*/
|
|
95
|
+
export declare function decideNetworkTarget(ruleset: CompiledRuleset, target: NetworkTarget, options: NetworkDecisionOptions): NetworkDecision;
|
|
96
|
+
/** Whether a target addresses the loopback range. */
|
|
97
|
+
export declare function isLoopbackTarget(target: NetworkTarget): boolean;
|
|
98
|
+
/** Whether a string is an IP literal (v4 or v6). */
|
|
99
|
+
export declare function isIpLiteral(host: string): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Parse a URL string into a NetworkTarget.
|
|
102
|
+
* Returns undefined for malformed URLs.
|
|
103
|
+
*/
|
|
104
|
+
export declare function parseUrlTarget(url: string): NetworkTarget | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* The short structured message a blocked proxy connection receives.
|
|
107
|
+
* `ask` at proxy layer degrades to block + audit (no session context).
|
|
108
|
+
*/
|
|
109
|
+
export declare function blockMessage(decision: NetworkDecision): string;
|
package/lib/network.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { compileCidr, compilePortSpec, compileDomainPattern } from './compiler.js';
|
|
2
|
+
/** Closed mode list for runtime normalization. */
|
|
3
|
+
export const NETWORK_MODES = ['deny-all', 'whitelist', 'allow-all'];
|
|
4
|
+
// ─── Mode mapping ──────────────────────────────────────────────────────────
|
|
5
|
+
/**
|
|
6
|
+
* Map one official sandbox preset onto its network mode.
|
|
7
|
+
* `read-only` → deny-all, `workspace-write` → whitelist,
|
|
8
|
+
* `danger-full-access` → allow-all. Unknown → fallback.
|
|
9
|
+
*/
|
|
10
|
+
export function networkModeForSandbox(sandbox, fallback) {
|
|
11
|
+
switch (sandbox) {
|
|
12
|
+
case 'read-only': return 'deny-all';
|
|
13
|
+
case 'workspace-write': return 'whitelist';
|
|
14
|
+
case 'danger-full-access': return 'allow-all';
|
|
15
|
+
default: return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The default decision a mode yields for an unmatched target. */
|
|
19
|
+
export function defaultDecision(mode, unlisted) {
|
|
20
|
+
switch (mode) {
|
|
21
|
+
case 'deny-all':
|
|
22
|
+
return { action: 'deny', matched: false, mode };
|
|
23
|
+
case 'whitelist':
|
|
24
|
+
return { action: unlisted, matched: false, mode };
|
|
25
|
+
case 'allow-all':
|
|
26
|
+
return { action: 'allow', matched: false, mode };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ─── Target matching ───────────────────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* Evaluate the network policy for one target against a compiled ruleset.
|
|
32
|
+
* First-match wins across deny → allow → ask partitions (deny-first).
|
|
33
|
+
*
|
|
34
|
+
* Proxy-layer tool attribution: shell subprocess connections carry
|
|
35
|
+
* `bash`/`pwsh` identity; rules scoped to other tools never fire.
|
|
36
|
+
* Agent identity is unknown at the proxy → agent-scoped rules fail-closed.
|
|
37
|
+
*
|
|
38
|
+
* **Unattributed traffic is exempt by default.** A connection that cannot be
|
|
39
|
+
* tied to an in-flight shell execution did not come from a subprocess the gate
|
|
40
|
+
* manages — it is DSH's own client. Reviewing it would let the host block
|
|
41
|
+
* itself, so it passes through (see {@link UnattributedAction}).
|
|
42
|
+
*/
|
|
43
|
+
export function decideNetworkTarget(ruleset, target, options) {
|
|
44
|
+
if (options.loopback === 'allow' && isLoopbackTarget(target)) {
|
|
45
|
+
return { action: 'allow', matched: false, mode: options.mode };
|
|
46
|
+
}
|
|
47
|
+
// Not from a shell subprocess → not this surface's to police.
|
|
48
|
+
if (options.attributed === false && (options.unattributed ?? 'allow') === 'allow') {
|
|
49
|
+
return { action: 'allow', matched: false, mode: options.mode, unattributed: true };
|
|
50
|
+
}
|
|
51
|
+
// Scan deny → allow → ask (deny-first).
|
|
52
|
+
for (const partition of [ruleset.deny, ruleset.allow, ruleset.ask]) {
|
|
53
|
+
for (const rule of partition) {
|
|
54
|
+
if (!rule.enabled)
|
|
55
|
+
continue;
|
|
56
|
+
if (rule.network === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
if (!matchToolsProxy(rule))
|
|
59
|
+
continue;
|
|
60
|
+
if (!targetMatchesNetwork(target, rule.network))
|
|
61
|
+
continue;
|
|
62
|
+
return {
|
|
63
|
+
action: rule.action,
|
|
64
|
+
matched: true,
|
|
65
|
+
mode: options.mode,
|
|
66
|
+
ruleIndex: rule.index,
|
|
67
|
+
rule,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return defaultDecision(options.mode, options.unlisted);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Tool-scope check for proxy traffic: shell subprocess connections carry
|
|
75
|
+
* `bash`/`pwsh` identity. Rules scoped to other tools never fire at proxy.
|
|
76
|
+
*/
|
|
77
|
+
function matchToolsProxy(rule) {
|
|
78
|
+
if (rule.tools.length === 0)
|
|
79
|
+
return true;
|
|
80
|
+
// Match if any tool pattern matches 'bash' or 'pwsh'.
|
|
81
|
+
return rule.tools.some((t) => t.re.test('bash') || t.re.test('pwsh'));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Check if a network target matches a rule's network dimension.
|
|
85
|
+
* All present sub-dimensions must match (AND); within a sub-dimension, entries are OR.
|
|
86
|
+
*/
|
|
87
|
+
function targetMatchesNetwork(target, network) {
|
|
88
|
+
// domains
|
|
89
|
+
if (network.domains !== undefined && network.domains.length > 0) {
|
|
90
|
+
const matchers = network.domains.map((d) => compileDomainPattern(d));
|
|
91
|
+
if (!matchers.some((m) => m.re.test(target.host)))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
// ips
|
|
95
|
+
if (network.ips !== undefined && network.ips.length > 0) {
|
|
96
|
+
const allIps = [...target.ips];
|
|
97
|
+
if (target.host !== undefined)
|
|
98
|
+
allIps.push(target.host);
|
|
99
|
+
const cidrs = network.ips.filter((s) => s.includes('/'));
|
|
100
|
+
const literals = network.ips.filter((s) => !s.includes('/'));
|
|
101
|
+
const ipMatch = allIps.some((ip) => literals.includes(ip) || cidrs.some((c) => {
|
|
102
|
+
try {
|
|
103
|
+
return compileCidr(c)(ip);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}));
|
|
109
|
+
if (!ipMatch)
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// ports
|
|
113
|
+
if (network.ports !== undefined && network.ports.length > 0) {
|
|
114
|
+
if (target.port === undefined)
|
|
115
|
+
return false;
|
|
116
|
+
const matchers = network.ports.map((p) => {
|
|
117
|
+
try {
|
|
118
|
+
return compilePortSpec(p);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return (_) => false;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
if (!matchers.some((m) => m(target.port)))
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
// schemes
|
|
128
|
+
if (network.schemes !== undefined && network.schemes.length > 0) {
|
|
129
|
+
if (target.scheme === undefined)
|
|
130
|
+
return false;
|
|
131
|
+
if (!network.schemes.includes(target.scheme))
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
137
|
+
/** Whether a target addresses the loopback range. */
|
|
138
|
+
export function isLoopbackTarget(target) {
|
|
139
|
+
const host = target.host;
|
|
140
|
+
if (host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1')
|
|
141
|
+
return true;
|
|
142
|
+
const parts = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
143
|
+
if (parts === null)
|
|
144
|
+
return false;
|
|
145
|
+
return parts.slice(1).every((o) => Number(o) <= 255) && parts[1] === '127';
|
|
146
|
+
}
|
|
147
|
+
/** Whether a string is an IP literal (v4 or v6). */
|
|
148
|
+
export function isIpLiteral(host) {
|
|
149
|
+
return /^[\d.:a-fA-F]+$/.test(host) && (host.includes(':') || /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Parse a URL string into a NetworkTarget.
|
|
153
|
+
* Returns undefined for malformed URLs.
|
|
154
|
+
*/
|
|
155
|
+
export function parseUrlTarget(url) {
|
|
156
|
+
try {
|
|
157
|
+
const u = new URL(url);
|
|
158
|
+
const host = u.hostname.toLowerCase().replace(/\.+$/, '');
|
|
159
|
+
if (host.length === 0)
|
|
160
|
+
return undefined;
|
|
161
|
+
const port = u.port !== '' ? Number(u.port) : (u.protocol === 'https:' ? 443 : u.protocol === 'http:' ? 80 : undefined);
|
|
162
|
+
return {
|
|
163
|
+
host,
|
|
164
|
+
port: port !== undefined && Number.isFinite(port) ? port : undefined,
|
|
165
|
+
scheme: u.protocol.replace(/:$/, ''),
|
|
166
|
+
ips: isIpLiteral(host) ? [host] : [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The short structured message a blocked proxy connection receives.
|
|
175
|
+
* `ask` at proxy layer degrades to block + audit (no session context).
|
|
176
|
+
*/
|
|
177
|
+
export function blockMessage(decision) {
|
|
178
|
+
const reason = decision.rule?.reason ?? '';
|
|
179
|
+
if (decision.matched) {
|
|
180
|
+
const rulePart = decision.ruleIndex !== undefined ? ` by rule ${decision.ruleIndex + 1}` : '';
|
|
181
|
+
if (decision.action === 'ask') {
|
|
182
|
+
return `[network: blocked pending approval${rulePart}] ${reason}\n(subprocess connections cannot ride the interactive approval seam — ask your user or add an allow rule for this target)`;
|
|
183
|
+
}
|
|
184
|
+
return `[network: denied${rulePart}] ${reason}`;
|
|
185
|
+
}
|
|
186
|
+
switch (decision.mode) {
|
|
187
|
+
case 'deny-all':
|
|
188
|
+
return '[network: denied] network mode deny-all (read-only sandbox preset): only whitelisted targets are reachable';
|
|
189
|
+
case 'whitelist':
|
|
190
|
+
return decision.action === 'ask'
|
|
191
|
+
? '[network: blocked pending approval] whitelist mode: the target is not matched by an allow rule'
|
|
192
|
+
: '[network: denied] whitelist mode: the target is not matched by an allow rule';
|
|
193
|
+
case 'allow-all':
|
|
194
|
+
return '[network: denied]';
|
|
195
|
+
}
|
|
196
|
+
}
|
package/lib/proxy.d.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { NetworkDecision, NetworkMode, NetworkTarget } from './network.js';
|
|
2
|
+
/** Proxy env var names the injector sets/restores (upper + lower for mixed-ecosystem CLIs). */
|
|
3
|
+
export declare const PROXY_ENV_NAMES: readonly string[];
|
|
4
|
+
/** NO_PROXY env var names cleared by the injector. */
|
|
5
|
+
export declare const NO_PROXY_ENV_NAMES: readonly string[];
|
|
6
|
+
/** Default DNS lookup bound (ms). A black-holed resolver must not hang a CONNECT. */
|
|
7
|
+
export declare const DNS_TIMEOUT_MS = 3000;
|
|
8
|
+
/** Default connection-setup bound (ms) — slowloris guard. */
|
|
9
|
+
export declare const HEADERS_TIMEOUT_MS = 60000;
|
|
10
|
+
/** Default cap on concurrent accepted connections. */
|
|
11
|
+
export declare const MAX_CONNECTIONS = 256;
|
|
12
|
+
/** Bound on how long close() waits for a server to finish closing (ms). */
|
|
13
|
+
export declare const CLOSE_TIMEOUT_MS = 2000;
|
|
14
|
+
/** One recorded proxy-layer block. */
|
|
15
|
+
export interface NetworkBlockRecord {
|
|
16
|
+
readonly time: number;
|
|
17
|
+
readonly tool: string;
|
|
18
|
+
readonly attributed: boolean;
|
|
19
|
+
readonly callId?: string;
|
|
20
|
+
readonly domain: string;
|
|
21
|
+
readonly scheme?: string;
|
|
22
|
+
readonly port?: number;
|
|
23
|
+
readonly action: 'deny' | 'ask';
|
|
24
|
+
readonly mode: NetworkMode;
|
|
25
|
+
readonly matched: boolean;
|
|
26
|
+
readonly source: string;
|
|
27
|
+
readonly ruleIndex?: number;
|
|
28
|
+
readonly reason?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Cumulative proxy-layer block counters. */
|
|
31
|
+
export interface NetworkStats {
|
|
32
|
+
denied: number;
|
|
33
|
+
askBlocked: number;
|
|
34
|
+
}
|
|
35
|
+
/** Attribution the runtime supplies for one connection. */
|
|
36
|
+
export interface ProxyAttribution {
|
|
37
|
+
readonly tool: string;
|
|
38
|
+
readonly callId?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Construction options for NetworkProxy. */
|
|
41
|
+
export interface NetworkProxyOptions {
|
|
42
|
+
/** Bind address (loopback by config). */
|
|
43
|
+
readonly bind: string;
|
|
44
|
+
/** Requested port; `0` binds an ephemeral port. */
|
|
45
|
+
readonly port: number;
|
|
46
|
+
/** Cap on recent-block records kept in memory. */
|
|
47
|
+
readonly maxRecent: number;
|
|
48
|
+
/** Decision function supplied by the runtime. */
|
|
49
|
+
readonly decide: (target: NetworkTarget) => NetworkDecision | Promise<NetworkDecision>;
|
|
50
|
+
/**
|
|
51
|
+
* Escalate an `ask` decision to the interactive approval seam. Called ONLY
|
|
52
|
+
* when {@link decide} returns `ask` — a `deny` is never escalated, so the
|
|
53
|
+
* rule review cannot be bypassed by approving a connection.
|
|
54
|
+
*
|
|
55
|
+
* Absent, throwing, or timing out all mean "not allowed": the connection is
|
|
56
|
+
* blocked exactly as it would have been.
|
|
57
|
+
*/
|
|
58
|
+
readonly escalate?: (target: NetworkTarget, decision: NetworkDecision) => Promise<'allow' | 'deny'>;
|
|
59
|
+
/** Current attribution (newest in-flight shell execution). */
|
|
60
|
+
readonly attribution?: () => ProxyAttribution | undefined;
|
|
61
|
+
/** Called for every blocked connection. */
|
|
62
|
+
readonly onBlock?: (record: NetworkBlockRecord, attribution: ProxyAttribution | undefined) => void;
|
|
63
|
+
/** Bind a DNS lookup with this timeout (ms). Default {@link DNS_TIMEOUT_MS}. */
|
|
64
|
+
readonly dnsTimeoutMs?: number;
|
|
65
|
+
/** Connection-setup bound (ms). Default {@link HEADERS_TIMEOUT_MS}. */
|
|
66
|
+
readonly headersTimeoutMs?: number;
|
|
67
|
+
/** Cap on concurrent connections. Default {@link MAX_CONNECTIONS}. */
|
|
68
|
+
readonly maxConnections?: number;
|
|
69
|
+
/** Logger sink (proxy failures must never crash the host). */
|
|
70
|
+
readonly logger: {
|
|
71
|
+
warn(message: string): void;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The local HTTP/CONNECT policy proxy. Binds on demand; every live tunnel
|
|
76
|
+
* socket is tracked and destroyed on close so updates and uninstalls leave
|
|
77
|
+
* no orphaned connections.
|
|
78
|
+
*/
|
|
79
|
+
export declare class NetworkProxy {
|
|
80
|
+
private readonly options;
|
|
81
|
+
private server;
|
|
82
|
+
private readonly sockets;
|
|
83
|
+
private readonly recent;
|
|
84
|
+
private readonly stats;
|
|
85
|
+
private actualPort;
|
|
86
|
+
/** In-flight start(); guards against a second concurrent bind. */
|
|
87
|
+
private starting;
|
|
88
|
+
/** Set by close(); a bind resolving afterwards tears itself down. */
|
|
89
|
+
private closed;
|
|
90
|
+
constructor(options: NetworkProxyOptions);
|
|
91
|
+
/**
|
|
92
|
+
* Logger sink that can never throw. The logger is called from inside
|
|
93
|
+
* 'error' handlers, so a throwing logger would itself become an
|
|
94
|
+
* unhandled error and defeat the whole point of the guard.
|
|
95
|
+
*/
|
|
96
|
+
private safeWarn;
|
|
97
|
+
/** The bound port (valid after start resolves). */
|
|
98
|
+
get port(): number;
|
|
99
|
+
/** Deny/ask blocks recorded since mount, newest first. */
|
|
100
|
+
recentBlocks(): readonly NetworkBlockRecord[];
|
|
101
|
+
/** Cumulative block counters. */
|
|
102
|
+
blockStats(): NetworkStats;
|
|
103
|
+
/** Number of active sockets (tunnels + client connections). */
|
|
104
|
+
activeSocketCount(): number;
|
|
105
|
+
/**
|
|
106
|
+
* Bind the server and return the actual port.
|
|
107
|
+
* On bind failure: warns and returns -1 (degraded mode — no proxy).
|
|
108
|
+
* Concurrent calls share the single in-flight bind.
|
|
109
|
+
*/
|
|
110
|
+
start(): Promise<number>;
|
|
111
|
+
private bind;
|
|
112
|
+
/**
|
|
113
|
+
* Stop the server and destroy every tunnel socket.
|
|
114
|
+
*
|
|
115
|
+
* Safe against a concurrent (in-flight) {@link start}: the `closed` flag
|
|
116
|
+
* makes a bind that resolves after this call tear itself down instead of
|
|
117
|
+
* leaving an orphaned listening server behind.
|
|
118
|
+
*/
|
|
119
|
+
close(): Promise<void>;
|
|
120
|
+
private handleRequest;
|
|
121
|
+
private handleConnect;
|
|
122
|
+
private forwardOrBlock;
|
|
123
|
+
/**
|
|
124
|
+
* DNS-resolve a hostname so `ips`-scoped rules see real addresses, then
|
|
125
|
+
* decide. Resolution failure → decide on the literal name.
|
|
126
|
+
*
|
|
127
|
+
* The lookup is **time-bounded**: an unbounded `lookup()` on a black-holed
|
|
128
|
+
* resolver would hold the CONNECT socket open indefinitely, so a slow
|
|
129
|
+
* resolver degrades to literal-name evaluation instead of hanging the
|
|
130
|
+
* client. `ips`-scoped rules simply cannot fire in that case.
|
|
131
|
+
*/
|
|
132
|
+
private decideWithResolution;
|
|
133
|
+
/**
|
|
134
|
+
* The final decision for one target: the rule/mode verdict, then — only for
|
|
135
|
+
* an `ask` — the interactive approval seam.
|
|
136
|
+
*
|
|
137
|
+
* Escalation is deliberately narrow:
|
|
138
|
+
* - A `deny` verdict is NEVER escalated. Approving a connection can widen
|
|
139
|
+
* reach for a target no rule allows, but it can never override a rule that
|
|
140
|
+
* says no. The rule review stays authoritative.
|
|
141
|
+
* - An escalation that throws, is absent, or returns anything but `allow`
|
|
142
|
+
* leaves the verdict blocked.
|
|
143
|
+
*/
|
|
144
|
+
private resolveDecision;
|
|
145
|
+
private recordBlock;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Inject proxy environment variables for subprocesses and return a disposer
|
|
149
|
+
* restoring every previous value exactly.
|
|
150
|
+
*
|
|
151
|
+
* The snapshot pass covers EVERY name BEFORE any write: on Windows
|
|
152
|
+
* `process.env` is case-insensitive, so writing `HTTP_PROXY` mid-loop
|
|
153
|
+
* would poison the later snapshot of `http_proxy`.
|
|
154
|
+
*
|
|
155
|
+
* @param port - the bound proxy port.
|
|
156
|
+
* @param noProxy - `'clear'` empties NO_PROXY so policy cannot be bypassed;
|
|
157
|
+
* `'preserve'` keeps ambient values.
|
|
158
|
+
* @returns the restore disposer.
|
|
159
|
+
*/
|
|
160
|
+
export declare function injectProxyEnv(port: number, noProxy: 'clear' | 'preserve'): () => void;
|