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.
Files changed (49) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/INSTALL.ja.md +57 -1
  3. package/INSTALL.ko.md +54 -1
  4. package/INSTALL.md +53 -1
  5. package/INSTALL.zh.md +48 -1
  6. package/README.ja.md +86 -12
  7. package/README.ko.md +83 -12
  8. package/README.md +120 -24
  9. package/README.zh.md +88 -10
  10. package/cordis.patch.yml +34 -2
  11. package/dsh.plugin.json +22 -0
  12. package/lib/agent-identity.d.ts +43 -0
  13. package/lib/agent-identity.js +95 -0
  14. package/lib/client.js +114 -0
  15. package/lib/client.js.map +1 -1
  16. package/lib/compiler.d.ts +39 -0
  17. package/lib/compiler.js +106 -0
  18. package/lib/config.d.ts +95 -2
  19. package/lib/config.js +28 -3
  20. package/lib/engine.js +9 -2
  21. package/lib/evaluate.d.ts +9 -0
  22. package/lib/evaluate.js +189 -0
  23. package/lib/events.d.ts +13 -0
  24. package/lib/events.js +31 -0
  25. package/lib/index.d.ts +1 -1
  26. package/lib/index.js +235 -6
  27. package/lib/network-lifecycle.d.ts +107 -0
  28. package/lib/network-lifecycle.js +180 -0
  29. package/lib/network.d.ts +109 -0
  30. package/lib/network.js +196 -0
  31. package/lib/proxy.d.ts +160 -0
  32. package/lib/proxy.js +529 -0
  33. package/lib/risk.js +25 -3
  34. package/lib/rule-chain.d.ts +39 -0
  35. package/lib/rule-chain.js +222 -0
  36. package/lib/rule-dims.d.ts +139 -0
  37. package/lib/rule-dims.js +194 -0
  38. package/lib/rule.d.ts +25 -0
  39. package/lib/rule.js +15 -1
  40. package/lib/runtime.d.ts +87 -0
  41. package/lib/runtime.js +327 -2
  42. package/lib/session-sweep.d.ts +45 -0
  43. package/lib/session-sweep.js +153 -0
  44. package/lib/shadow.d.ts +34 -0
  45. package/lib/shadow.js +143 -0
  46. package/lib/watch.d.ts +42 -0
  47. package/lib/watch.js +170 -0
  48. package/package.json +8 -2
  49. 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.js CHANGED
@@ -43,9 +43,9 @@ const RISK_SYSTEM_PROMPT = [
43
43
  ' {"risk":"risky","category":"<category>","reason":"short"}',
44
44
  '',
45
45
  'category must be exactly one of:',
46
- ' deletion — deletes or overwrites data that cannot be regenerated',
46
+ ' deletion — deletes or overwrites data that cannot be regenerated (IMPORTANT: see exclusion rules below)',
47
47
  ' credential — creates/modifies/exposes credentials, keys, tokens, auth config',
48
- ' remote — affects remote systems, production, databases, messaging, billing, publishing',
48
+ ' remote — WRITES to or MODIFIES remote systems (git push, git deploy, kubectl apply, terraform apply, production database writes, messaging, billing, publishing). Must have a write/modify/delete effect on the remote side.',
49
49
  ' system — system-level paths or configuration (/etc, /usr, boot items), shutdown/reboot',
50
50
  ' bulk — mass overwrite of many files, formatting, dd-style irreversible writes',
51
51
  ' neutral — none of the above clearly applies (ordinary edits outside the obvious scope, or you are unsure but see no hard-risk sign)',
@@ -54,6 +54,17 @@ const RISK_SYSTEM_PROMPT = [
54
54
  '- safe is only for reversible, in-scope operations that touch no sensitive resource.',
55
55
  '- When unsure between safe and neutral, answer neutral; never guess safe.',
56
56
  '- Judge the operation content, not merely its location.',
57
+ '',
58
+ 'DELETION exclusion rules (these override the deletion category):',
59
+ '- Cleanup of temporary files, build outputs, or failed clone directories is SAFE — even if using rm/rmdir/Remove-Item. Look for temp paths: paths containing "temp", "tmp", "test-clone", "node_modules", ".cache", "dist", "build", or numbered/timestamped directories.',
60
+ '- Removal of session-local artifacts (files the session itself created) is SAFE.',
61
+ '- rm -rf on node_modules, .next, __pycache__, .pytest_cache, .git, dist, build is SAFE — these are always regenerable.',
62
+ '- Deletion inside the workspace that targets non-existent or already-empty paths is SAFE (idempotent cleanup).',
63
+ '- Only flag as deletion when the target is: (a) user data with no backup, (b) configuration files, (c) database records, (d) files outside the workspace that were not created by this session, or (e) a destructive rm -rf on a directory containing unique, irreplaceable content.',
64
+ '',
65
+ 'READ-ONLY remote operations are safe: git fetch, git pull, git clone, git log, git diff, git status, git show, npm install (read-only), pip install (read-only), apt-get update, curl/wget (read, no write flag).',
66
+ '- Operations that only READ from remote systems without modifying them are safe, even if they involve network traffic.',
67
+ '- Only flag as remote when the operation WRITES, DEPLOYS, PUSHES, or DELETES on the remote side.',
57
68
  ].join('\n');
58
69
  /** Parse one assistant message into a verdict; anything off-protocol → undefined (caller retries/fails). */
59
70
  export function parseRiskVerdict(content) {
@@ -92,9 +103,20 @@ export async function classifyRisk(cfg, req, nowFetch = fetch) {
92
103
  }
93
104
  /** The bounded user prompt for one risk grading (shared by every transport). */
94
105
  export function riskUserText(req) {
106
+ // Enrich the prompt with full path context so the LLM can judge
107
+ // whether a deletion targets a temporary/build artifact vs real data.
108
+ const args = (req.args ?? {});
109
+ const enriched = { ...args };
110
+ // Surface the working directory if present (helps LLM see temp paths).
111
+ if (typeof enriched.cwd === 'string' && enriched.cwd !== '') {
112
+ enriched.__cwd = enriched.cwd;
113
+ }
114
+ if (typeof enriched.workdir === 'string' && enriched.workdir !== '') {
115
+ enriched.__cwd = enriched.workdir;
116
+ }
95
117
  return JSON.stringify({
96
118
  tool: req.tool,
97
- args: req.args ?? {},
119
+ args: enriched,
98
120
  reason: req.reason,
99
121
  instruction: 'Reply strictly as one JSON object: {"risk":"safe"} or {"risk":"risky","category":"..."} plus a short "reason".',
100
122
  });
@@ -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;