dsh-perm-gate 2.1.0 → 3.0.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.ja.md +82 -0
- package/CHANGELOG.ko.md +80 -0
- package/CHANGELOG.md +321 -0
- package/INSTALL.ja.md +70 -8
- package/INSTALL.ko.md +68 -9
- package/INSTALL.md +68 -8
- package/INSTALL.zh.md +59 -7
- package/README.ja.md +103 -16
- package/README.ko.md +101 -17
- package/README.md +152 -25
- package/README.zh.md +99 -12
- package/cordis.patch.yml +34 -3
- package/docs/baseline-guard.md +56 -0
- package/docs/plan-rules-to-settings.md +217 -0
- package/docs/rules-format.en.md +294 -0
- package/docs/rules-format.md +279 -0
- package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
- package/dsh.plugin.json +22 -0
- package/examples/permissions.example.yaml +16 -1
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +598 -6
- package/lib/client.js.map +1 -1
- package/lib/command-dispatcher.d.ts +22 -0
- package/lib/command-dispatcher.js +97 -0
- package/lib/command-semantics.d.ts +62 -0
- package/lib/command-semantics.js +6 -0
- package/lib/compiler.d.ts +50 -0
- package/lib/compiler.js +122 -0
- package/lib/config.d.ts +161 -7
- package/lib/config.js +78 -11
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +61 -4
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +264 -1
- package/lib/events.d.ts +69 -2
- package/lib/events.js +123 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +320 -11
- 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/parsers/git.d.ts +43 -0
- package/lib/parsers/git.js +404 -0
- package/lib/parsers/shell-cmds.d.ts +32 -0
- package/lib/parsers/shell-cmds.js +293 -0
- package/lib/preset.d.ts +13 -0
- package/lib/preset.js +17 -0
- package/lib/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +19 -6
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +213 -0
- package/lib/rule-dims.d.ts +174 -0
- package/lib/rule-dims.js +230 -0
- package/lib/rule.d.ts +60 -0
- package/lib/rule.js +69 -7
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +208 -7
- package/lib/runtime.js +433 -27
- 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/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +15 -2
- package/scripts/patch-permission-glyph.mjs +209 -0
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in local HTTP/CONNECT proxy for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* The process-level network gate for shell subprocess traffic. Bash/pwsh
|
|
5
|
+
* children inherit `HTTP(S)_PROXY`/`ALL_PROXY` environment variables
|
|
6
|
+
* pointing here, and the proxy adjudicates EVERY connection target against
|
|
7
|
+
* the loaded network rules and the active policy mode.
|
|
8
|
+
*
|
|
9
|
+
* Boundaries:
|
|
10
|
+
* - DSH's `ctx.sandbox` enforces file effects only; network interception
|
|
11
|
+
* is entirely this plugin's job.
|
|
12
|
+
* - The proxy has no session context, so `ask` decisions degrade to
|
|
13
|
+
* block + audit (web tools get the real approval seam at pre-execute).
|
|
14
|
+
* - Bind failures degrade gracefully: warn + continue without proxy.
|
|
15
|
+
*/
|
|
16
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
17
|
+
import { request as httpsRequest } from 'node:https';
|
|
18
|
+
import { connect } from 'node:net';
|
|
19
|
+
import { lookup } from 'node:dns/promises';
|
|
20
|
+
import { blockMessage, isIpLiteral } from './network.js';
|
|
21
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
22
|
+
/** Proxy env var names the injector sets/restores (upper + lower for mixed-ecosystem CLIs). */
|
|
23
|
+
export const PROXY_ENV_NAMES = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'];
|
|
24
|
+
/** NO_PROXY env var names cleared by the injector. */
|
|
25
|
+
export const NO_PROXY_ENV_NAMES = ['NO_PROXY', 'no_proxy'];
|
|
26
|
+
/** Default DNS lookup bound (ms). A black-holed resolver must not hang a CONNECT. */
|
|
27
|
+
export const DNS_TIMEOUT_MS = 3000;
|
|
28
|
+
/** Default connection-setup bound (ms) — slowloris guard. */
|
|
29
|
+
export const HEADERS_TIMEOUT_MS = 60_000;
|
|
30
|
+
/** Default cap on concurrent accepted connections. */
|
|
31
|
+
export const MAX_CONNECTIONS = 256;
|
|
32
|
+
/** Bound on how long close() waits for a server to finish closing (ms). */
|
|
33
|
+
export const CLOSE_TIMEOUT_MS = 2000;
|
|
34
|
+
// ─── NetworkProxy ──────────────────────────────────────────────────────────
|
|
35
|
+
/**
|
|
36
|
+
* The local HTTP/CONNECT policy proxy. Binds on demand; every live tunnel
|
|
37
|
+
* socket is tracked and destroyed on close so updates and uninstalls leave
|
|
38
|
+
* no orphaned connections.
|
|
39
|
+
*/
|
|
40
|
+
export class NetworkProxy {
|
|
41
|
+
options;
|
|
42
|
+
server;
|
|
43
|
+
sockets = new Set();
|
|
44
|
+
recent = [];
|
|
45
|
+
stats = { denied: 0, askBlocked: 0 };
|
|
46
|
+
actualPort = 0;
|
|
47
|
+
/** In-flight start(); guards against a second concurrent bind. */
|
|
48
|
+
starting;
|
|
49
|
+
/** Set by close(); a bind resolving afterwards tears itself down. */
|
|
50
|
+
closed = false;
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.options = options;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Logger sink that can never throw. The logger is called from inside
|
|
56
|
+
* 'error' handlers, so a throwing logger would itself become an
|
|
57
|
+
* unhandled error and defeat the whole point of the guard.
|
|
58
|
+
*/
|
|
59
|
+
safeWarn(message) {
|
|
60
|
+
try {
|
|
61
|
+
this.options.logger.warn(message);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// A logger that throws must never escalate into a host crash.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The bound port (valid after start resolves). */
|
|
68
|
+
get port() {
|
|
69
|
+
return this.actualPort;
|
|
70
|
+
}
|
|
71
|
+
/** Deny/ask blocks recorded since mount, newest first. */
|
|
72
|
+
recentBlocks() {
|
|
73
|
+
return this.recent;
|
|
74
|
+
}
|
|
75
|
+
/** Cumulative block counters. */
|
|
76
|
+
blockStats() {
|
|
77
|
+
return { ...this.stats };
|
|
78
|
+
}
|
|
79
|
+
/** Number of active sockets (tunnels + client connections). */
|
|
80
|
+
activeSocketCount() {
|
|
81
|
+
return this.sockets.size;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Bind the server and return the actual port.
|
|
85
|
+
* On bind failure: warns and returns -1 (degraded mode — no proxy).
|
|
86
|
+
* Concurrent calls share the single in-flight bind.
|
|
87
|
+
*/
|
|
88
|
+
async start() {
|
|
89
|
+
if (this.starting !== undefined)
|
|
90
|
+
return this.starting;
|
|
91
|
+
this.starting = this.bind();
|
|
92
|
+
try {
|
|
93
|
+
return await this.starting;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
this.starting = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async bind() {
|
|
100
|
+
const server = createServer((req, res) => {
|
|
101
|
+
// The request arrived: lift the pre-request idle bound.
|
|
102
|
+
req.socket.setTimeout(0);
|
|
103
|
+
this.handleRequest(req, res).catch((err) => {
|
|
104
|
+
this.safeWarn(`[dsh-perm-gate] proxy request error: ${String(err)}`);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
server.on('connect', (req, socket, head) => {
|
|
108
|
+
// CONNECT parsed: lift the pre-request idle bound so the tunnel may
|
|
109
|
+
// stay open for as long as the client needs.
|
|
110
|
+
;
|
|
111
|
+
socket.setTimeout(0);
|
|
112
|
+
this.handleConnect(req, socket, head).catch((err) => {
|
|
113
|
+
this.safeWarn(`[dsh-perm-gate] proxy connect error: ${String(err)}`);
|
|
114
|
+
if (!socket.destroyed)
|
|
115
|
+
socket.destroy();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
server.on('error', (error) => {
|
|
119
|
+
this.safeWarn(`[dsh-perm-gate] proxy server error: ${String(error)}`);
|
|
120
|
+
});
|
|
121
|
+
// T2.9 safety: catch client socket errors that slip through individual
|
|
122
|
+
// request handlers (ECONNRESET after 403, etc.) — never crash the host.
|
|
123
|
+
server.on('clientError', (err, socket) => {
|
|
124
|
+
if (err && err.code !== 'ECONNRESET' && err.message !== 'socket hang up') {
|
|
125
|
+
this.safeWarn(`[dsh-perm-gate] proxy clientError: ${String(err)}`);
|
|
126
|
+
}
|
|
127
|
+
if (!socket.destroyed)
|
|
128
|
+
socket.destroy();
|
|
129
|
+
});
|
|
130
|
+
// ULTIMATE safety net: every accepted socket gets an error handler the
|
|
131
|
+
// moment it connects, before any request/CONNECT parsing. Without this,
|
|
132
|
+
// any socket-level error (ECONNRESET from a client that received a 403
|
|
133
|
+
// block and hung up) becomes an unhandled 'error' event and kills the
|
|
134
|
+
// entire DSH process. A policy proxy must NEVER take down its host.
|
|
135
|
+
server.on('connection', (socket) => {
|
|
136
|
+
socket.on('error', (err) => {
|
|
137
|
+
if (err && err.code !== 'ECONNRESET' && err.message !== 'socket hang up') {
|
|
138
|
+
this.safeWarn(`[dsh-perm-gate] proxy socket error: ${String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
if (!socket.destroyed)
|
|
141
|
+
socket.destroy();
|
|
142
|
+
});
|
|
143
|
+
socket.setTimeout(this.options.headersTimeoutMs ?? HEADERS_TIMEOUT_MS, () => {
|
|
144
|
+
socket.destroy();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
this.server = server;
|
|
148
|
+
// Slowloris guard: bound the connection-setup phase. A peer that opens a
|
|
149
|
+
// socket and dribbles headers would otherwise hold a connection forever.
|
|
150
|
+
// CONNECT is emitted as soon as headers parse, so this protects setup
|
|
151
|
+
// without touching the (legitimately long-lived) tunnel phase.
|
|
152
|
+
server.headersTimeout = this.options.headersTimeoutMs ?? HEADERS_TIMEOUT_MS;
|
|
153
|
+
// Bounded concurrency: a policy proxy must not accumulate unbounded
|
|
154
|
+
// sockets. Excess connections are refused by the server rather than
|
|
155
|
+
// growing the tracked set without limit.
|
|
156
|
+
server.maxConnections = this.options.maxConnections ?? MAX_CONNECTIONS;
|
|
157
|
+
try {
|
|
158
|
+
const port = await new Promise((resolve, reject) => {
|
|
159
|
+
const onError = (error) => {
|
|
160
|
+
server.off('listening', onListening);
|
|
161
|
+
reject(error);
|
|
162
|
+
};
|
|
163
|
+
const onListening = () => {
|
|
164
|
+
server.off('error', onError);
|
|
165
|
+
const address = server.address();
|
|
166
|
+
resolve(address.port);
|
|
167
|
+
};
|
|
168
|
+
server.once('error', onError);
|
|
169
|
+
server.once('listening', onListening);
|
|
170
|
+
server.listen(this.options.port, this.options.bind);
|
|
171
|
+
});
|
|
172
|
+
this.actualPort = port;
|
|
173
|
+
// close() landed while the bind was in flight: this server is already
|
|
174
|
+
// orphaned, so tear it down here rather than leaving a listening socket
|
|
175
|
+
// (and a rewritten environment) behind.
|
|
176
|
+
if (this.closed) {
|
|
177
|
+
this.actualPort = 0;
|
|
178
|
+
this.server = undefined;
|
|
179
|
+
try {
|
|
180
|
+
server.close();
|
|
181
|
+
}
|
|
182
|
+
catch { /* never started */ }
|
|
183
|
+
return -1;
|
|
184
|
+
}
|
|
185
|
+
return port;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
// T2.9: Bind failure degradation — warn + continue without proxy.
|
|
189
|
+
this.safeWarn(`[dsh-perm-gate] proxy bind failed (port ${this.options.port}): ${String(error)} — continuing without network proxy`);
|
|
190
|
+
this.server = undefined;
|
|
191
|
+
this.actualPort = -1;
|
|
192
|
+
return -1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Stop the server and destroy every tunnel socket.
|
|
197
|
+
*
|
|
198
|
+
* Safe against a concurrent (in-flight) {@link start}: the `closed` flag
|
|
199
|
+
* makes a bind that resolves after this call tear itself down instead of
|
|
200
|
+
* leaving an orphaned listening server behind.
|
|
201
|
+
*/
|
|
202
|
+
async close() {
|
|
203
|
+
this.closed = true;
|
|
204
|
+
// An in-flight bind owns the server handle. Let it settle first: it sees
|
|
205
|
+
// `closed` and tears itself down, so we never race a close against a
|
|
206
|
+
// server that has not finished listening (whose close callback may never
|
|
207
|
+
// fire).
|
|
208
|
+
const pending = this.starting;
|
|
209
|
+
if (pending !== undefined) {
|
|
210
|
+
try {
|
|
211
|
+
await pending;
|
|
212
|
+
}
|
|
213
|
+
catch { /* start() reports -1 on failure */ }
|
|
214
|
+
}
|
|
215
|
+
const server = this.server;
|
|
216
|
+
this.server = undefined;
|
|
217
|
+
for (const socket of this.sockets)
|
|
218
|
+
socket.destroy();
|
|
219
|
+
this.sockets.clear();
|
|
220
|
+
this.actualPort = 0;
|
|
221
|
+
if (server === undefined)
|
|
222
|
+
return;
|
|
223
|
+
await new Promise((resolve) => {
|
|
224
|
+
let settled = false;
|
|
225
|
+
const done = () => { if (!settled) {
|
|
226
|
+
settled = true;
|
|
227
|
+
resolve();
|
|
228
|
+
} };
|
|
229
|
+
try {
|
|
230
|
+
server.close(done);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
done();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Bounded: a server that never started listening can leave the close
|
|
237
|
+
// callback pending forever, and close() must always resolve.
|
|
238
|
+
const timer = setTimeout(done, CLOSE_TIMEOUT_MS);
|
|
239
|
+
timer.unref?.();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// ─── HTTP proxy ──────────────────────────────────────────────────────
|
|
243
|
+
async handleRequest(req, res) {
|
|
244
|
+
// Attach error handlers IMMEDIATELY (before any async work): a client that
|
|
245
|
+
// receives a 403 block closes its socket with RST, and the resulting
|
|
246
|
+
// ECONNRESET on req/res must never reach an unhandled 'error' event.
|
|
247
|
+
const suppressStreamError = (err) => {
|
|
248
|
+
if (err && err.code !== 'ECONNRESET' && err.message !== 'socket hang up') {
|
|
249
|
+
this.safeWarn(`[dsh-perm-gate] proxy stream error: ${String(err)}`);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
req.on('error', suppressStreamError);
|
|
253
|
+
res.on('error', suppressStreamError);
|
|
254
|
+
const target = parseUrlTarget(req.url ?? '');
|
|
255
|
+
if (target === undefined || target.scheme === undefined) {
|
|
256
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
257
|
+
res.end('[dsh-perm-gate] proxy: only absolute-form proxy requests are served\n');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
await this.forwardOrBlock(res, target, () => {
|
|
261
|
+
const upstream = new URL(req.url);
|
|
262
|
+
const send = upstream.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
263
|
+
const proxyReq = send(upstream.href, { method: req.method, headers: req.headers }, (proxyRes) => {
|
|
264
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
265
|
+
proxyRes.pipe(res);
|
|
266
|
+
});
|
|
267
|
+
proxyReq.on('error', (error) => {
|
|
268
|
+
if (!res.headersSent) {
|
|
269
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
270
|
+
res.end(`[network: upstream error] ${String(error)}\n`);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
res.destroy();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
// Suppress ECONNRESET from client closing after receiving 403/502.
|
|
277
|
+
req.on('error', (err) => {
|
|
278
|
+
if (err && err.code !== 'ECONNRESET') {
|
|
279
|
+
this.safeWarn(`[dsh-perm-gate] proxy req error: ${String(err)}`);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
req.pipe(proxyReq);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// ─── CONNECT tunnel ──────────────────────────────────────────────────
|
|
286
|
+
async handleConnect(req, socket, head) {
|
|
287
|
+
// T2.8/T2.9 safety: attach error handler IMMEDIATELY to prevent unhandled
|
|
288
|
+
// errors from crashing the host process. Client may RST at any point
|
|
289
|
+
// (e.g. after receiving a 403 block), and upstream may fail to connect.
|
|
290
|
+
const tunnel = {};
|
|
291
|
+
const suppressError = (err) => {
|
|
292
|
+
// Suppress ECONNRESET / EPIPE after a 403 block — expected behavior.
|
|
293
|
+
// Only log unexpected errors.
|
|
294
|
+
if (err && err.code !== 'ECONNRESET' && err.message !== 'socket hang up') {
|
|
295
|
+
this.safeWarn(`[dsh-perm-gate] proxy connect error: ${String(err)}`);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
socket.on('error', (err) => {
|
|
299
|
+
suppressError(err);
|
|
300
|
+
tunnel.upstream?.destroy();
|
|
301
|
+
});
|
|
302
|
+
const target = connectTarget(req.url ?? '');
|
|
303
|
+
if (target === undefined) {
|
|
304
|
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const decision = await this.resolveDecision(target);
|
|
308
|
+
if (decision.action !== 'allow') {
|
|
309
|
+
this.recordBlock(decision, target);
|
|
310
|
+
const body = blockMessage(decision);
|
|
311
|
+
socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const upstream = connect(target.port ?? 443, target.host);
|
|
315
|
+
tunnel.upstream = upstream;
|
|
316
|
+
this.sockets.add(socket);
|
|
317
|
+
this.sockets.add(upstream);
|
|
318
|
+
const cleanup = () => {
|
|
319
|
+
this.sockets.delete(socket);
|
|
320
|
+
this.sockets.delete(upstream);
|
|
321
|
+
};
|
|
322
|
+
socket.on('close', cleanup);
|
|
323
|
+
upstream.on('close', cleanup);
|
|
324
|
+
upstream.on('error', (err) => {
|
|
325
|
+
suppressError(err);
|
|
326
|
+
socket.destroy();
|
|
327
|
+
});
|
|
328
|
+
upstream.once('connect', () => {
|
|
329
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
330
|
+
if (head.length > 0)
|
|
331
|
+
upstream.write(head);
|
|
332
|
+
upstream.pipe(socket);
|
|
333
|
+
socket.pipe(upstream);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
// ─── Decision pipeline ───────────────────────────────────────────────
|
|
337
|
+
async forwardOrBlock(res, target, forward) {
|
|
338
|
+
const decision = await this.resolveDecision(target);
|
|
339
|
+
if (decision.action !== 'allow') {
|
|
340
|
+
this.recordBlock(decision, target);
|
|
341
|
+
const body = blockMessage(decision);
|
|
342
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(body) });
|
|
343
|
+
res.end(body);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
forward();
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* DNS-resolve a hostname so `ips`-scoped rules see real addresses, then
|
|
350
|
+
* decide. Resolution failure → decide on the literal name.
|
|
351
|
+
*
|
|
352
|
+
* The lookup is **time-bounded**: an unbounded `lookup()` on a black-holed
|
|
353
|
+
* resolver would hold the CONNECT socket open indefinitely, so a slow
|
|
354
|
+
* resolver degrades to literal-name evaluation instead of hanging the
|
|
355
|
+
* client. `ips`-scoped rules simply cannot fire in that case.
|
|
356
|
+
*/
|
|
357
|
+
async decideWithResolution(target) {
|
|
358
|
+
if (!isIpLiteral(target.host)) {
|
|
359
|
+
try {
|
|
360
|
+
const addresses = await withTimeout(lookup(target.host, { all: true, verbatim: true }), this.options.dnsTimeoutMs ?? DNS_TIMEOUT_MS);
|
|
361
|
+
const resolved = addresses.map((entry) => entry.address);
|
|
362
|
+
if (resolved.length > 0) {
|
|
363
|
+
return await this.options.decide({ ...target, ips: [...target.ips, ...resolved] });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Unresolvable or too slow: decide on the literal name.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return await this.options.decide(target);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The final decision for one target: the rule/mode verdict, then — only for
|
|
374
|
+
* an `ask` — the interactive approval seam.
|
|
375
|
+
*
|
|
376
|
+
* Escalation is deliberately narrow:
|
|
377
|
+
* - A `deny` verdict is NEVER escalated. Approving a connection can widen
|
|
378
|
+
* reach for a target no rule allows, but it can never override a rule that
|
|
379
|
+
* says no. The rule review stays authoritative.
|
|
380
|
+
* - An escalation that throws, is absent, or returns anything but `allow`
|
|
381
|
+
* leaves the verdict blocked.
|
|
382
|
+
*/
|
|
383
|
+
async resolveDecision(target) {
|
|
384
|
+
const decision = await this.decideWithResolution(target);
|
|
385
|
+
if (decision.action !== 'ask')
|
|
386
|
+
return decision;
|
|
387
|
+
const escalate = this.options.escalate;
|
|
388
|
+
if (escalate === undefined)
|
|
389
|
+
return decision;
|
|
390
|
+
let verdict = 'deny';
|
|
391
|
+
try {
|
|
392
|
+
verdict = await escalate(target, decision);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
this.safeWarn(`[dsh-perm-gate] network escalation failed: ${String(error)}`);
|
|
396
|
+
verdict = 'deny';
|
|
397
|
+
}
|
|
398
|
+
if (verdict !== 'allow')
|
|
399
|
+
return decision;
|
|
400
|
+
// Approved: the connection proceeds, and the audit keeps the rule context
|
|
401
|
+
// that produced the ask (`matched` / `ruleIndex` are preserved).
|
|
402
|
+
return { ...decision, action: 'allow' };
|
|
403
|
+
}
|
|
404
|
+
// ─── Block recording ─────────────────────────────────────────────────
|
|
405
|
+
recordBlock(decision, target) {
|
|
406
|
+
const attribution = this.options.attribution?.();
|
|
407
|
+
const record = {
|
|
408
|
+
time: Date.now(),
|
|
409
|
+
tool: attribution?.tool ?? 'subprocess',
|
|
410
|
+
attributed: attribution !== undefined,
|
|
411
|
+
...(attribution?.callId !== undefined ? { callId: attribution.callId } : {}),
|
|
412
|
+
domain: target.host,
|
|
413
|
+
...(target.scheme !== undefined ? { scheme: target.scheme } : {}),
|
|
414
|
+
...(target.port !== undefined ? { port: target.port } : {}),
|
|
415
|
+
action: decision.action === 'ask' ? 'ask' : 'deny',
|
|
416
|
+
mode: decision.mode,
|
|
417
|
+
matched: decision.matched,
|
|
418
|
+
source: decision.source ?? '',
|
|
419
|
+
...(decision.ruleIndex !== undefined ? { ruleIndex: decision.ruleIndex } : {}),
|
|
420
|
+
...(decision.rule !== undefined ? { reason: decision.rule.reason } : {}),
|
|
421
|
+
};
|
|
422
|
+
if (record.action === 'deny')
|
|
423
|
+
this.stats.denied += 1;
|
|
424
|
+
else
|
|
425
|
+
this.stats.askBlocked += 1;
|
|
426
|
+
this.recent.unshift(record);
|
|
427
|
+
if (this.recent.length > this.options.maxRecent)
|
|
428
|
+
this.recent.length = this.options.maxRecent;
|
|
429
|
+
this.safeWarn(`[dsh-perm-gate] network ${record.action === 'deny' ? 'denied' : 'ask-blocked'} ${target.scheme ?? '?'}://${target.host}${target.port !== undefined ? `:${target.port}` : ''} (mode ${decision.mode}${decision.matched ? `, rule ${(decision.ruleIndex ?? 0) + 1}` : ', mode default'})`);
|
|
430
|
+
try {
|
|
431
|
+
this.options.onBlock?.(record, attribution);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
this.safeWarn(`[dsh-perm-gate] network block hook failed: ${String(error)}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// ─── Environment injection ─────────────────────────────────────────────────
|
|
439
|
+
/**
|
|
440
|
+
* Inject proxy environment variables for subprocesses and return a disposer
|
|
441
|
+
* restoring every previous value exactly.
|
|
442
|
+
*
|
|
443
|
+
* The snapshot pass covers EVERY name BEFORE any write: on Windows
|
|
444
|
+
* `process.env` is case-insensitive, so writing `HTTP_PROXY` mid-loop
|
|
445
|
+
* would poison the later snapshot of `http_proxy`.
|
|
446
|
+
*
|
|
447
|
+
* @param port - the bound proxy port.
|
|
448
|
+
* @param noProxy - `'clear'` empties NO_PROXY so policy cannot be bypassed;
|
|
449
|
+
* `'preserve'` keeps ambient values.
|
|
450
|
+
* @returns the restore disposer.
|
|
451
|
+
*/
|
|
452
|
+
export function injectProxyEnv(port, noProxy) {
|
|
453
|
+
const previous = new Map();
|
|
454
|
+
const value = `http://127.0.0.1:${port}`;
|
|
455
|
+
// Snapshot ALL names before any write.
|
|
456
|
+
for (const name of PROXY_ENV_NAMES)
|
|
457
|
+
previous.set(name, process.env[name]);
|
|
458
|
+
if (noProxy === 'clear') {
|
|
459
|
+
for (const name of NO_PROXY_ENV_NAMES)
|
|
460
|
+
previous.set(name, process.env[name]);
|
|
461
|
+
}
|
|
462
|
+
// Write proxy env.
|
|
463
|
+
for (const name of PROXY_ENV_NAMES)
|
|
464
|
+
process.env[name] = value;
|
|
465
|
+
if (noProxy === 'clear') {
|
|
466
|
+
for (const name of NO_PROXY_ENV_NAMES)
|
|
467
|
+
process.env[name] = '';
|
|
468
|
+
}
|
|
469
|
+
// Return restore disposer.
|
|
470
|
+
return () => {
|
|
471
|
+
for (const name of PROXY_ENV_NAMES) {
|
|
472
|
+
const old = previous.get(name);
|
|
473
|
+
if (old === undefined)
|
|
474
|
+
delete process.env[name];
|
|
475
|
+
else
|
|
476
|
+
process.env[name] = old;
|
|
477
|
+
}
|
|
478
|
+
if (noProxy === 'clear') {
|
|
479
|
+
for (const name of NO_PROXY_ENV_NAMES) {
|
|
480
|
+
const old = previous.get(name);
|
|
481
|
+
if (old === undefined)
|
|
482
|
+
delete process.env[name];
|
|
483
|
+
else
|
|
484
|
+
process.env[name] = old;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
490
|
+
/** Parse a CONNECT authority (`host:port`) into an https target. */
|
|
491
|
+
function connectTarget(authority) {
|
|
492
|
+
const colon = authority.lastIndexOf(':');
|
|
493
|
+
if (colon <= 0)
|
|
494
|
+
return undefined;
|
|
495
|
+
const host = authority.slice(0, colon).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.+$/, '');
|
|
496
|
+
const port = Number(authority.slice(colon + 1));
|
|
497
|
+
if (host.length === 0 || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
498
|
+
return undefined;
|
|
499
|
+
return { scheme: 'https', host, port, ips: isIpLiteral(host) ? [host] : [] };
|
|
500
|
+
}
|
|
501
|
+
/** Parse a URL string into a NetworkTarget. */
|
|
502
|
+
function parseUrlTarget(url) {
|
|
503
|
+
try {
|
|
504
|
+
const u = new URL(url);
|
|
505
|
+
const host = u.hostname.toLowerCase().replace(/\.+$/, '');
|
|
506
|
+
if (host.length === 0)
|
|
507
|
+
return undefined;
|
|
508
|
+
const port = u.port !== '' ? Number(u.port) : (u.protocol === 'https:' ? 443 : u.protocol === 'http:' ? 80 : undefined);
|
|
509
|
+
return {
|
|
510
|
+
host,
|
|
511
|
+
port: port !== undefined && Number.isFinite(port) ? port : undefined,
|
|
512
|
+
scheme: u.protocol.replace(/:$/, ''),
|
|
513
|
+
ips: isIpLiteral(host) ? [host] : [],
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Reject after `ms` if `promise` has not settled. The timer is always
|
|
522
|
+
* cleared, so a resolved lookup leaves nothing behind.
|
|
523
|
+
*/
|
|
524
|
+
function withTimeout(promise, ms) {
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
const timer = setTimeout(() => reject(new Error(`DNS lookup exceeded ${ms}ms`)), ms);
|
|
527
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error instanceof Error ? error : new Error(String(error))); });
|
|
528
|
+
});
|
|
529
|
+
}
|
package/lib/risk.d.ts
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* safe → the gate may auto-allow the ask
|
|
8
8
|
* risky:<hard category> → deletion / credential / remote / system / bulk —
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* the operation looks dangerous; the gate KEEPS the
|
|
10
|
+
* human ask and never learns these into an
|
|
11
|
+
* auto-allow. It is NOT an auto-deny: denying is
|
|
12
|
+
* reserved for the deterministic layers (P0,
|
|
13
|
+
* deny-keywords, `deny:` rules), because a
|
|
14
|
+
* probabilistic verdict must not be able to hand
|
|
15
|
+
* down an unappealable block.
|
|
11
16
|
* risky:neutral → no hard-risk signal but not clearly safe; the
|
|
12
17
|
* verdict-learning path may auto-allow after
|
|
13
18
|
* enough human confirmations
|
|
@@ -25,9 +30,18 @@ export type RiskCategory = 'deletion' | 'credential' | 'remote' | 'system' | 'bu
|
|
|
25
30
|
/**
|
|
26
31
|
* Categories that always route to the human seam regardless of learning state
|
|
27
32
|
* or any later LLM opinion. Order-stable for prompts and event payloads.
|
|
33
|
+
*
|
|
34
|
+
* "Hard" means *never auto-allowed and never learned* — the gate keeps asking.
|
|
35
|
+
* It does not mean auto-denied: the deny path belongs to the deterministic
|
|
36
|
+
* layers alone (P0 hard-deny, the deny-keyword blacklist, explicit `deny:`
|
|
37
|
+
* rules), so a misgraded category can always be negotiated instead of becoming
|
|
38
|
+
* an unappealable block.
|
|
28
39
|
*/
|
|
29
40
|
export declare const HARD_RISK_CATEGORIES: readonly RiskCategory[];
|
|
30
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Whether a category must never be auto-allowed — and never learned into one.
|
|
43
|
+
* Unknown categories are hard too (fail-closed).
|
|
44
|
+
*/
|
|
31
45
|
export declare function isHardRisk(category: string): boolean;
|
|
32
46
|
/**
|
|
33
47
|
* A graded verdict for one tool call. `unresolved` covers every failure mode —
|
package/lib/risk.js
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* safe → the gate may auto-allow the ask
|
|
8
8
|
* risky:<hard category> → deletion / credential / remote / system / bulk —
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* the operation looks dangerous; the gate KEEPS the
|
|
10
|
+
* human ask and never learns these into an
|
|
11
|
+
* auto-allow. It is NOT an auto-deny: denying is
|
|
12
|
+
* reserved for the deterministic layers (P0,
|
|
13
|
+
* deny-keywords, `deny:` rules), because a
|
|
14
|
+
* probabilistic verdict must not be able to hand
|
|
15
|
+
* down an unappealable block.
|
|
11
16
|
* risky:neutral → no hard-risk signal but not clearly safe; the
|
|
12
17
|
* verdict-learning path may auto-allow after
|
|
13
18
|
* enough human confirmations
|
|
@@ -23,10 +28,19 @@ import { chatCompletion, withLlmRetry } from './classifier.js';
|
|
|
23
28
|
/**
|
|
24
29
|
* Categories that always route to the human seam regardless of learning state
|
|
25
30
|
* or any later LLM opinion. Order-stable for prompts and event payloads.
|
|
31
|
+
*
|
|
32
|
+
* "Hard" means *never auto-allowed and never learned* — the gate keeps asking.
|
|
33
|
+
* It does not mean auto-denied: the deny path belongs to the deterministic
|
|
34
|
+
* layers alone (P0 hard-deny, the deny-keyword blacklist, explicit `deny:`
|
|
35
|
+
* rules), so a misgraded category can always be negotiated instead of becoming
|
|
36
|
+
* an unappealable block.
|
|
26
37
|
*/
|
|
27
38
|
export const HARD_RISK_CATEGORIES = ['deletion', 'credential', 'remote', 'system', 'bulk'];
|
|
28
39
|
const RISK_CATEGORIES = [...HARD_RISK_CATEGORIES, 'neutral'];
|
|
29
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Whether a category must never be auto-allowed — and never learned into one.
|
|
42
|
+
* Unknown categories are hard too (fail-closed).
|
|
43
|
+
*/
|
|
30
44
|
export function isHardRisk(category) {
|
|
31
45
|
if (HARD_RISK_CATEGORIES.includes(category))
|
|
32
46
|
return true;
|
|
@@ -105,9 +119,8 @@ export async function classifyRisk(cfg, req, nowFetch = fetch) {
|
|
|
105
119
|
export function riskUserText(req) {
|
|
106
120
|
// Enrich the prompt with full path context so the LLM can judge
|
|
107
121
|
// whether a deletion targets a temporary/build artifact vs real data.
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
const enriched = { ...argsRecord };
|
|
122
|
+
const args = (req.args ?? {});
|
|
123
|
+
const enriched = { ...args };
|
|
111
124
|
// Surface the working directory if present (helps LLM see temp paths).
|
|
112
125
|
if (typeof enriched.cwd === 'string' && enriched.cwd !== '') {
|
|
113
126
|
enriched.__cwd = enriched.cwd;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type PermissionsDoc, type CompiledRuleset, type CompileOptions } from './rule.js';
|
|
2
|
+
export interface RuleChainConfig {
|
|
3
|
+
/** The rules file name to search for (e.g. `rules.yml`). */
|
|
4
|
+
readonly rulesFile: string;
|
|
5
|
+
/** Whether to search up directory ancestors from cwd. Default false. */
|
|
6
|
+
readonly searchUp?: boolean;
|
|
7
|
+
/** Fallback path when no file is found in the chain. */
|
|
8
|
+
readonly fallbackPath?: string;
|
|
9
|
+
/** Error policy for malformed files: `fail` (throw) or `warn` (skip). Default fail. */
|
|
10
|
+
readonly badFilePolicy?: 'fail' | 'warn';
|
|
11
|
+
/** Maximum number of files in the chain. Default 10. */
|
|
12
|
+
readonly maxChainLength?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ChainEntry {
|
|
15
|
+
/** Absolute path to the rules file. */
|
|
16
|
+
readonly path: string;
|
|
17
|
+
/** Content hash (compile-cache key). */
|
|
18
|
+
readonly hash: string;
|
|
19
|
+
/** Parsed document. */
|
|
20
|
+
readonly doc: PermissionsDoc;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the rule chain: find all applicable rules files and merge them.
|
|
24
|
+
*
|
|
25
|
+
* @param cwd - The workspace root directory.
|
|
26
|
+
* @param config - Chain configuration.
|
|
27
|
+
* @param opts - Compile options forwarded to each file's compiler.
|
|
28
|
+
* @returns The merged compiled ruleset.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveRuleChain(cwd: string, config: RuleChainConfig, opts?: CompileOptions): CompiledRuleset;
|
|
31
|
+
/**
|
|
32
|
+
* Get or create a compiled ruleset from the cache.
|
|
33
|
+
* Returns the cached version if the hash matches; otherwise compiles fresh.
|
|
34
|
+
*/
|
|
35
|
+
export declare function cachedCompile(text: string, opts?: CompileOptions): CompiledRuleset;
|
|
36
|
+
/** Clear the compile cache (used in tests and on reload). */
|
|
37
|
+
export declare function clearCompileCache(): void;
|
|
38
|
+
/** Current compile cache size (diagnostics). */
|
|
39
|
+
export declare function compileCacheSize(): number;
|