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/index.js CHANGED
@@ -1,8 +1,13 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { join } from 'node:path';
2
- import { Config, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
3
- import { registerEventsRoute, registerHealthRoute, registerLearningRoute, registerReceiverRoute, registerReviewRoutes } from './events.js';
3
+ import { Config, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
4
+ import { registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes } from './events.js';
4
5
  import { buildReceiverInfo } from './receiver-info.js';
5
6
  import { PermGateRuntime } from './runtime.js';
7
+ import { classifySessions, sweepSessionData } from './session-sweep.js';
8
+ import { decideNetworkTarget } from './network.js';
9
+ import { NetworkLifecycle } from './network-lifecycle.js';
10
+ import { RuleWatcher } from './watch.js';
6
11
  export const name = 'dsh-perm-gate';
7
12
  /**
8
13
  * The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
@@ -13,6 +18,8 @@ export const name = 'dsh-perm-gate';
13
18
  */
14
19
  export const inject = ['tools', 'webServer', 'llm', 'agentDefaultModel'];
15
20
  export { Config };
21
+ /** The closed approval-outcome vocabulary (anything else is normalized to `unavailable`). */
22
+ const APPROVAL_OUTCOMES = new Set(['allowed-once', 'rejected', 'cancelled', 'unavailable']);
16
23
  /** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
17
24
  export const PERMISSIVE_NAMESPACE = 'dsh-perm-gate';
18
25
  /**
@@ -137,10 +144,14 @@ export function makePreExecuteListener(runtime) {
137
144
  return async (exec, next) => {
138
145
  const decision = runtime.decideExecution(exec);
139
146
  // Passthrough (allow / stand-down) and a hard-deny need no grading.
140
- if (decision === undefined)
147
+ if (decision === undefined) {
148
+ // The call proceeds: register it so a network connection made by its
149
+ // child process can be attributed back to this session.
150
+ runtime.beginShellExecution(exec);
141
151
  return next();
152
+ }
142
153
  if (decision.kind !== 'ask')
143
- return decision;
154
+ return decision; // deny: it never runs
144
155
  // A cancelled call is never worth a model round-trip; the registry rechecks
145
156
  // cancellation after this gate settles.
146
157
  if (exec.signal?.aborted === true)
@@ -152,7 +163,15 @@ export function makePreExecuteListener(runtime) {
152
163
  catch {
153
164
  return decision; // fail-closed: keep the human ask
154
165
  }
155
- return refined ?? next();
166
+ if (refined === undefined) {
167
+ runtime.beginShellExecution(exec);
168
+ return next();
169
+ }
170
+ // Still an ask: the human decides. Register optimistically so a call they
171
+ // approve is attributable; a rejection is dropped by the abort/settle path.
172
+ if (refined.kind === 'ask')
173
+ runtime.beginShellExecution(exec);
174
+ return refined;
156
175
  };
157
176
  }
158
177
  export function apply(ctx, config = {}) {
@@ -181,6 +200,28 @@ export function apply(ctx, config = {}) {
181
200
  ? llmService
182
201
  : undefined;
183
202
  const hostModelService = injected.agentDefaultModel ?? fallbackGet('agentDefaultModel');
203
+ // Host log channel, probed rather than assumed: cordis exposes `ctx.logger`,
204
+ // but the shape moved between DSH lines. Falls back to console so a warning
205
+ // is never silently dropped — and never throws, because these calls happen
206
+ // inside error handlers where a throw would escalate into a host crash.
207
+ const ctxLogger = (() => {
208
+ const candidate = ctx.logger;
209
+ if (candidate !== null && typeof candidate === 'object' && typeof candidate.warn === 'function') {
210
+ return candidate;
211
+ }
212
+ return undefined;
213
+ })();
214
+ const loggerWarn = (message) => {
215
+ try {
216
+ if (ctxLogger !== undefined)
217
+ ctxLogger.warn(message);
218
+ else
219
+ console.warn(message);
220
+ }
221
+ catch {
222
+ // A throwing logger must never become an unhandled error.
223
+ }
224
+ };
184
225
  // The live approval service, captured by the optional inject below. Its
185
226
  // `effectivePolicy` tells the gate whether an ask can reach a human at all.
186
227
  //
@@ -196,6 +237,32 @@ export function apply(ctx, config = {}) {
196
237
  const runtime = new PermGateRuntime({
197
238
  ...config,
198
239
  hostLlm,
240
+ // Raise a network approval through the DSH approval seam. The service
241
+ // applies the session policy, routes the prompt to the agent, and appends
242
+ // the `approval/asked`/`approval/decided` audit pair. Everything that is
243
+ // not an explicit `allowed-once` fails closed to `unavailable`, and the
244
+ // gate turns that into a block.
245
+ requestApproval: async (req) => {
246
+ const svc = approvalService;
247
+ if (svc === undefined || typeof svc.request !== 'function')
248
+ return 'unavailable';
249
+ try {
250
+ const outcome = await svc.request({
251
+ agent: req.agent,
252
+ toolName: req.toolName,
253
+ ...(req.callId !== undefined ? { callId: req.callId } : {}),
254
+ reason: req.reason,
255
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
256
+ });
257
+ return APPROVAL_OUTCOMES.has(String(outcome))
258
+ ? outcome
259
+ : 'unavailable';
260
+ }
261
+ catch {
262
+ // No open turn, a missing answerer, or a seam failure: never an allow.
263
+ return 'unavailable';
264
+ }
265
+ },
199
266
  // Read the Permissive tier live so the UI card's switches take effect on
200
267
  // the next tool call (no reload). Falls back to the composition entry.
201
268
  readPermissive: () => {
@@ -290,6 +357,57 @@ export function apply(ctx, config = {}) {
290
357
  ? config.snapshotsDir
291
358
  : join(dataDir, 'snapshots'),
292
359
  });
360
+ // Session-lifecycle sweep: on startup and hourly, classify every session the
361
+ // gate holds data for against DSH's workspace store, and drop the
362
+ // authorization-chain data (decision events + pre-change snapshots) of
363
+ // sessions that were archived or no longer exist. The store is read-only
364
+ // here and every failure is fail-open (a skipped round retries in an hour);
365
+ // the timers are unref'd so cleanup never holds the process open.
366
+ if (config.sessionSweep !== false) {
367
+ const storeFile = typeof config.workspaceStoreFile === 'string' && config.workspaceStoreFile !== ''
368
+ ? config.workspaceStoreFile
369
+ : join(resolveDshHome(typeof config.dshHome === 'string' ? config.dshHome : undefined), 'storages', 'workspace.json');
370
+ const sweepEventsFile = typeof config.eventsFile === 'string' && config.eventsFile !== ''
371
+ ? config.eventsFile
372
+ : join(dataDir, 'events.jsonl');
373
+ const sweepSnapshotsDir = typeof config.snapshotsDir === 'string' && config.snapshotsDir !== ''
374
+ ? config.snapshotsDir
375
+ : join(dataDir, 'snapshots');
376
+ const sweepOnce = () => {
377
+ try {
378
+ const cls = classifySessions(readFileSync(storeFile, 'utf8'));
379
+ if (cls === null)
380
+ return; // torn read / format change — retry next round
381
+ sweepSessionData({ classification: cls, eventsFile: sweepEventsFile, snapshotsDir: sweepSnapshotsDir });
382
+ }
383
+ catch (e) {
384
+ console.warn('[dsh-perm-gate] session sweep skipped:', e instanceof Error ? e.message : e);
385
+ }
386
+ };
387
+ const first = setTimeout(sweepOnce, 0);
388
+ const hourly = setInterval(sweepOnce, 60 * 60 * 1000);
389
+ hourly.unref?.();
390
+ ctx.effect(() => () => {
391
+ clearTimeout(first);
392
+ clearInterval(hourly);
393
+ }, 'dsh-perm-gate: session sweep');
394
+ }
395
+ /**
396
+ * The network-relevant slice of a settings surface, as a comparison key.
397
+ * Used to decide whether a settings edit needs a proxy rebind at all — an
398
+ * unrelated edit (say, the allowlist) must not close and reopen the proxy.
399
+ */
400
+ const networkKeyOf = (source) => {
401
+ const s = (source ?? {});
402
+ return JSON.stringify([
403
+ s.networkEnabled, s.networkMode, s.networkUnlisted, s.networkLoopback,
404
+ s.networkBind, s.networkPort, s.networkNoProxy, s.networkInjectEnv,
405
+ ]);
406
+ };
407
+ // Forward references, resolved during apply before any UI edit can fire:
408
+ // the settings watch only ever runs on a later user action. Held in an
409
+ // object so the mutable slots stay lint-clean and the intent is explicit.
410
+ const networkRefs = {};
293
411
  installSettingsSection(ctx, PERMISSIVE_NAMESPACE, Config, config, {
294
412
  setSource: (source) => {
295
413
  current = source;
@@ -310,6 +428,17 @@ export function apply(ctx, config = {}) {
310
428
  if (next !== undefined && Array.isArray(next.allowlist)) {
311
429
  runtime.setAllowlist(next.allowlist.filter((x) => typeof x === 'string'));
312
430
  }
431
+ // A network knob changed: re-mount the proxy so the card's switches
432
+ // take effect without a plugin reload. Only a rebind-relevant edit
433
+ // triggers it.
434
+ const key = networkKeyOf(next);
435
+ if (networkRefs.appliedKey !== undefined && networkRefs.appliedKey !== key) {
436
+ networkRefs.appliedKey = key;
437
+ networkRefs.rebind?.();
438
+ }
439
+ else if (networkRefs.appliedKey === undefined) {
440
+ networkRefs.appliedKey = key;
441
+ }
313
442
  });
314
443
  },
315
444
  });
@@ -352,6 +481,13 @@ export function apply(ctx, config = {}) {
352
481
  const offHealth = registerHealthRoute(webServer, { check: () => runtime.healthCheck() });
353
482
  if (offHealth !== undefined)
354
483
  ctx.effect(() => () => { offHealth(); }, 'dsh-perm-gate: health route');
484
+ // Network diagnostics: mode, bind, port, proxy liveness, env injection,
485
+ // block counters and recent blocks. Read-only.
486
+ const offNetwork = registerNetworkRoute(webServer, {
487
+ snapshot: () => networkRefs.snapshot?.() ?? { enabled: false, proxyActive: false },
488
+ });
489
+ if (offNetwork !== undefined)
490
+ ctx.effect(() => () => { offNetwork(); }, 'dsh-perm-gate: network route');
355
491
  // Receiver projection for the settings card (provider/model catalog is
356
492
  // potentially slow to enumerate — cached briefly).
357
493
  let receiverCache = null;
@@ -398,7 +534,12 @@ export function apply(ctx, config = {}) {
398
534
  // Fallback terminal-answer channel: settle the ask from the call's result,
399
535
  // and settle a pending learning candidate (a result for one means the human
400
536
  // approved it and it executed — one confirmation).
401
- host.on('tools/result', ((exec, result) => { runtime.settleExecution(exec, result); }));
537
+ host.on('tools/result', ((exec, result) => {
538
+ // The call settled: it is no longer in flight, so a later connection must
539
+ // not be attributed to it.
540
+ runtime.endShellExecution(exec);
541
+ runtime.settleExecution(exec, result);
542
+ }));
402
543
  // Approval channel: answer an escalation the gate already cleared, and settle
403
544
  // the outcome of an ask the gate did raise. Registered `prepend` so it sits at
404
545
  // the head of the `approval/request` waterfall, ahead of the remote bridge that
@@ -422,5 +563,93 @@ export function apply(ctx, config = {}) {
422
563
  approvalService = undefined;
423
564
  }, 'dsh-perm-gate: approval answerer');
424
565
  });
566
+ // ─── Network proxy (Phase 2, T2.12 + rebind) ────────────────────────
567
+ // T2.10 escape hatch: default OFF. The network proxy is opt-in — enabling
568
+ // it binds a loopback port and rewrites proxy env vars, so it must never
569
+ // be turned on implicitly.
570
+ //
571
+ // The lifecycle lives in NetworkLifecycle: one persistent teardown effect,
572
+ // serialized (re)binds, and a diagnostics snapshot. A settings change calls
573
+ // rebind(), so the card's switches take effect without a plugin reload.
574
+ const readNetworkConfig = () => {
575
+ const live = current();
576
+ const str = (key, fallback) => typeof live[key] === 'string' && live[key] !== '' ? live[key] : fallback;
577
+ const num = (key, fallback) => typeof live[key] === 'number' ? live[key] : fallback;
578
+ const bool = (key, fallback) => typeof live[key] === 'boolean' ? live[key] : fallback;
579
+ return {
580
+ enabled: bool('networkEnabled', false),
581
+ mode: str('networkMode', 'whitelist'),
582
+ unlisted: str('networkUnlisted', 'ask'),
583
+ unattributed: str('networkUnattributed', 'allow'),
584
+ loopback: str('networkLoopback', 'allow'),
585
+ bind: str('networkBind', '127.0.0.1'),
586
+ port: num('networkPort', 0),
587
+ noProxy: str('networkNoProxy', 'clear'),
588
+ injectEnv: bool('networkInjectEnv', true),
589
+ askTimeoutMs: num('networkAskTimeoutMs', 120_000),
590
+ };
591
+ };
592
+ const networkLogger = ctxLogger !== undefined ? ctxLogger : { warn: (msg) => console.warn(msg) };
593
+ const networkLifecycle = new NetworkLifecycle({
594
+ effect: (disposeFactory, label) => { ctx.effect(disposeFactory, label); },
595
+ readConfig: readNetworkConfig,
596
+ decide: (target) => {
597
+ const cfg = readNetworkConfig();
598
+ return decideNetworkTarget(runtime.compiledRuleset, target, {
599
+ mode: cfg.mode,
600
+ unlisted: cfg.unlisted,
601
+ loopback: cfg.loopback,
602
+ // Attribution decides whether this connection is a shell subprocess
603
+ // (the gate's business) or DSH's own client (left alone by default).
604
+ attributed: runtime.currentAttribution() !== undefined,
605
+ unattributed: cfg.unattributed,
606
+ });
607
+ },
608
+ attribution: () => runtime.currentAttribution(),
609
+ // An `ask` verdict escalates to the interactive approval seam, raised on
610
+ // behalf of the shell command that opened the connection. A `deny` verdict
611
+ // never reaches here — approval widens reach but cannot override a rule.
612
+ escalate: async (target, decision) => {
613
+ const where = `${target.scheme ?? 'https'}://${target.host}${target.port !== undefined ? `:${target.port}` : ''}`;
614
+ const why = decision.matched && decision.ruleIndex !== undefined
615
+ ? `rule #${decision.ruleIndex + 1} asks about it`
616
+ : `${decision.mode} mode has no allow rule for it`;
617
+ return runtime.askNetwork(target, `a shell subprocess wants to reach ${where} — ${why}. Approve to let this command use the network for this target.`, readNetworkConfig().askTimeoutMs);
618
+ },
619
+ logger: networkLogger,
620
+ });
621
+ // Never awaited at apply(): a slow or failing bind must not block plugin
622
+ // load. attach() degrades to "no proxy" internally.
623
+ void networkLifecycle.attach().catch((error) => {
624
+ loggerWarn(`[dsh-perm-gate] network proxy attach failed: ${String(error)}`);
625
+ });
626
+ // Resolve the forward reference and seed the change-detection key from the
627
+ // config actually applied, so the first UI edit is compared against it.
628
+ networkRefs.rebind = () => {
629
+ void networkLifecycle.rebind().catch((error) => {
630
+ loggerWarn(`[dsh-perm-gate] network proxy rebind failed: ${String(error)}`);
631
+ });
632
+ };
633
+ networkRefs.snapshot = () => networkLifecycle.snapshot();
634
+ if (networkRefs.appliedKey === undefined)
635
+ networkRefs.appliedKey = networkKeyOf(current());
636
+ // ─── Hot reload watcher (Phase 3, T3.6) ────────────────────────────
637
+ const watchEnabled = typeof config.watch === 'boolean' ? config.watch : true;
638
+ if (watchEnabled) {
639
+ const watchDebounceMs = typeof config.watchDebounceMs === 'number' ? config.watchDebounceMs : 300;
640
+ const ruleWatcher = new RuleWatcher({
641
+ debounceMs: watchDebounceMs,
642
+ logger: { warn: (msg) => console.warn(msg) },
643
+ });
644
+ // Watch the effective rules file for the current workspace.
645
+ const rulesFile = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
646
+ const cwd = typeof config.cwd === 'string' ? config.cwd : process.cwd();
647
+ ruleWatcher.watch(cwd, [rulesFile], () => {
648
+ runtime.reload();
649
+ });
650
+ ctx.effect(() => () => {
651
+ ruleWatcher.closeAll();
652
+ }, 'dsh-perm-gate: rule watcher');
653
+ }
425
654
  return runtime;
426
655
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Network proxy lifecycle for dsh-perm-gate.
3
+ *
4
+ * Owns the proxy + the subprocess environment injection and exposes the
5
+ * three operations the host needs:
6
+ *
7
+ * attach() — mount per the current config (called once at apply)
8
+ * rebind() — re-read the config and re-mount (called on a settings change)
9
+ * detach() — close the proxy and restore the environment
10
+ *
11
+ * Keeping this out of `index.ts` matters: the proxy has a real lifecycle
12
+ * (bind, env rewrite, teardown) and every step must be owned by exactly one
13
+ * disposer. `index.ts` only wires it.
14
+ *
15
+ * Robustness contract:
16
+ * - One persistent teardown effect is registered at construction, so a
17
+ * dispose that races an in-flight bind still closes the proxy and restores
18
+ * the environment.
19
+ * - Mutating operations are serialized: rapid settings toggles cannot
20
+ * interleave attach/detach and leave an orphaned listener or a rewritten
21
+ * `process.env`.
22
+ * - Nothing here throws into the host: failures degrade to "no proxy" and
23
+ * surface through the optional logger.
24
+ */
25
+ import { type NetworkBlockRecord, type ProxyAttribution } from './proxy.js';
26
+ import type { NetworkDecision, NetworkMode, NetworkTarget, UnattributedAction, UnlistedAction } from './network.js';
27
+ /** The live network configuration (read fresh on every operation). */
28
+ export interface NetworkConfigSnapshot {
29
+ readonly enabled: boolean;
30
+ readonly mode: NetworkMode;
31
+ readonly unlisted: UnlistedAction;
32
+ /** Handling for traffic with no shell attribution. Default `'allow'`. */
33
+ readonly unattributed: UnattributedAction;
34
+ readonly loopback: 'allow' | 'policy';
35
+ readonly bind: string;
36
+ readonly port: number;
37
+ readonly noProxy: 'clear' | 'preserve';
38
+ /** Rewrite `HTTP(S)_PROXY`/`ALL_PROXY` for subprocesses. Default true. */
39
+ readonly injectEnv: boolean;
40
+ /** How long an unlisted-target approval may wait for a human (ms). */
41
+ readonly askTimeoutMs: number;
42
+ }
43
+ /** The network state the settings page and HTTP route render. */
44
+ export interface NetworkSnapshot {
45
+ readonly enabled: boolean;
46
+ readonly mode: NetworkMode;
47
+ readonly bind: string;
48
+ readonly port: number;
49
+ readonly proxyActive: boolean;
50
+ readonly envInjected: boolean;
51
+ readonly denied: number;
52
+ readonly askBlocked: number;
53
+ readonly recent: readonly NetworkBlockRecord[];
54
+ }
55
+ export interface NetworkLifecycleOptions {
56
+ /** Register one disposer factory with the host plugin context. The factory
57
+ * is invoked at dispose time; it must RETURN the teardown function. */
58
+ readonly effect: (disposeFactory: () => () => void, label: string) => void;
59
+ /** Read the live network config (settings-aware). */
60
+ readonly readConfig: () => NetworkConfigSnapshot;
61
+ /** The decision function (reads the live ruleset). */
62
+ readonly decide: (target: NetworkTarget) => NetworkDecision | Promise<NetworkDecision>;
63
+ /**
64
+ * Escalate an `ask` verdict to the interactive approval seam. Never called
65
+ * for a `deny` — the rule review stays authoritative.
66
+ */
67
+ readonly escalate?: (target: NetworkTarget, decision: NetworkDecision) => Promise<'allow' | 'deny'>;
68
+ /** Attribution for block records. */
69
+ readonly attribution: () => ProxyAttribution | undefined;
70
+ /** Extra block observer (audit/event feed). */
71
+ readonly onBlock?: (record: NetworkBlockRecord, attribution: ProxyAttribution | undefined) => void;
72
+ readonly logger: {
73
+ warn(message: string): void;
74
+ info?(message: string): void;
75
+ };
76
+ }
77
+ /**
78
+ * Owns the proxy and the environment injection across (re)binds.
79
+ */
80
+ export declare class NetworkLifecycle {
81
+ private readonly options;
82
+ private proxy;
83
+ private envRestore;
84
+ private disposed;
85
+ /** Serializes mutations so rapid toggles cannot interleave. */
86
+ private queue;
87
+ constructor(options: NetworkLifecycleOptions);
88
+ /** Mount the proxy per the current config. No-op when disabled or live. */
89
+ attach(): Promise<void>;
90
+ /** Close the proxy and restore the environment. */
91
+ detach(): Promise<void>;
92
+ /**
93
+ * Re-read the config and re-mount: the old proxy is closed and the old
94
+ * environment restored before the new one is installed. This is what makes
95
+ * the settings-card switches take effect without a plugin reload.
96
+ */
97
+ rebind(): Promise<void>;
98
+ /** The diagnostics snapshot (safe to call at any time). */
99
+ snapshot(): NetworkSnapshot;
100
+ private run;
101
+ private attachLocked;
102
+ private detachLocked;
103
+ private closeProxyLocked;
104
+ private restoreEnvLocked;
105
+ private safeWarn;
106
+ private safeInfo;
107
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Network proxy lifecycle for dsh-perm-gate.
3
+ *
4
+ * Owns the proxy + the subprocess environment injection and exposes the
5
+ * three operations the host needs:
6
+ *
7
+ * attach() — mount per the current config (called once at apply)
8
+ * rebind() — re-read the config and re-mount (called on a settings change)
9
+ * detach() — close the proxy and restore the environment
10
+ *
11
+ * Keeping this out of `index.ts` matters: the proxy has a real lifecycle
12
+ * (bind, env rewrite, teardown) and every step must be owned by exactly one
13
+ * disposer. `index.ts` only wires it.
14
+ *
15
+ * Robustness contract:
16
+ * - One persistent teardown effect is registered at construction, so a
17
+ * dispose that races an in-flight bind still closes the proxy and restores
18
+ * the environment.
19
+ * - Mutating operations are serialized: rapid settings toggles cannot
20
+ * interleave attach/detach and leave an orphaned listener or a rewritten
21
+ * `process.env`.
22
+ * - Nothing here throws into the host: failures degrade to "no proxy" and
23
+ * surface through the optional logger.
24
+ */
25
+ import { NetworkProxy, injectProxyEnv } from './proxy.js';
26
+ /**
27
+ * Owns the proxy and the environment injection across (re)binds.
28
+ */
29
+ export class NetworkLifecycle {
30
+ options;
31
+ proxy;
32
+ envRestore;
33
+ disposed = false;
34
+ /** Serializes mutations so rapid toggles cannot interleave. */
35
+ queue = Promise.resolve();
36
+ constructor(options) {
37
+ this.options = options;
38
+ // Registered ONCE, before any bind: a dispose that races the first bind
39
+ // still tears the proxy down and restores the environment. `effect`
40
+ // expects a factory returning the disposer, so the disposer is returned.
41
+ options.effect(() => () => {
42
+ this.disposed = true;
43
+ void this.run(() => this.detachLocked());
44
+ }, 'dsh-perm-gate: network proxy');
45
+ }
46
+ /** Mount the proxy per the current config. No-op when disabled or live. */
47
+ attach() {
48
+ return this.run(() => this.attachLocked());
49
+ }
50
+ /** Close the proxy and restore the environment. */
51
+ detach() {
52
+ return this.run(() => this.detachLocked());
53
+ }
54
+ /**
55
+ * Re-read the config and re-mount: the old proxy is closed and the old
56
+ * environment restored before the new one is installed. This is what makes
57
+ * the settings-card switches take effect without a plugin reload.
58
+ */
59
+ rebind() {
60
+ return this.run(async () => {
61
+ await this.detachLocked();
62
+ await this.attachLocked();
63
+ });
64
+ }
65
+ /** The diagnostics snapshot (safe to call at any time). */
66
+ snapshot() {
67
+ const cfg = this.options.readConfig();
68
+ const proxy = this.proxy;
69
+ const stats = proxy?.blockStats() ?? { denied: 0, askBlocked: 0 };
70
+ const port = proxy?.port ?? 0;
71
+ return {
72
+ enabled: cfg.enabled,
73
+ mode: cfg.mode,
74
+ bind: cfg.bind,
75
+ port,
76
+ proxyActive: proxy !== undefined && port > 0,
77
+ envInjected: this.envRestore !== undefined,
78
+ denied: stats.denied,
79
+ askBlocked: stats.askBlocked,
80
+ recent: proxy?.recentBlocks() ?? [],
81
+ };
82
+ }
83
+ // ─── Internals (always called through the queue) ─────────────────────
84
+ run(work) {
85
+ const next = this.queue.then(() => work());
86
+ // Keep the chain alive through a rejected step; the step itself is
87
+ // responsible for degrading rather than throwing.
88
+ this.queue = next.then(() => undefined, () => undefined);
89
+ return next;
90
+ }
91
+ async attachLocked() {
92
+ if (this.disposed)
93
+ return;
94
+ const cfg = this.options.readConfig();
95
+ if (!cfg.enabled)
96
+ return;
97
+ // Already live: leave it alone (a rebind detaches first).
98
+ if (this.proxy !== undefined && this.proxy.port > 0)
99
+ return;
100
+ // A previous attempt may have left a proxy that failed to bind; drop it
101
+ // so the retry starts clean.
102
+ await this.closeProxyLocked();
103
+ this.restoreEnvLocked();
104
+ const proxy = new NetworkProxy({
105
+ bind: cfg.bind,
106
+ port: cfg.port,
107
+ maxRecent: 100,
108
+ decide: this.options.decide,
109
+ ...(this.options.escalate !== undefined ? { escalate: this.options.escalate } : {}),
110
+ attribution: this.options.attribution,
111
+ ...(this.options.onBlock !== undefined ? { onBlock: this.options.onBlock } : {}),
112
+ logger: this.options.logger,
113
+ });
114
+ this.proxy = proxy;
115
+ const port = await proxy.start();
116
+ // A dispose landed while binding: the effect already ran detach, but the
117
+ // proxy was created after it, so close it here.
118
+ if (this.disposed) {
119
+ await this.closeProxyLocked();
120
+ return;
121
+ }
122
+ if (port <= 0) {
123
+ // Bind failed (already logged by the proxy). Keep the handle so the
124
+ // snapshot can report `proxyActive: false`.
125
+ return;
126
+ }
127
+ if (cfg.injectEnv) {
128
+ try {
129
+ this.envRestore = injectProxyEnv(port, cfg.noProxy);
130
+ }
131
+ catch (error) {
132
+ this.safeWarn(`[dsh-perm-gate] proxy env injection failed: ${String(error)} — the proxy is live but subprocesses are not routed through it`);
133
+ }
134
+ }
135
+ this.safeInfo(`[dsh-perm-gate] network proxy listening on ${cfg.bind}:${port} (mode ${cfg.mode}${cfg.injectEnv ? ', env injected' : ', env not injected'})`);
136
+ }
137
+ async detachLocked() {
138
+ this.restoreEnvLocked();
139
+ await this.closeProxyLocked();
140
+ }
141
+ async closeProxyLocked() {
142
+ const proxy = this.proxy;
143
+ this.proxy = undefined;
144
+ if (proxy === undefined)
145
+ return;
146
+ try {
147
+ await proxy.close();
148
+ }
149
+ catch (error) {
150
+ this.safeWarn(`[dsh-perm-gate] proxy close failed: ${String(error)}`);
151
+ }
152
+ }
153
+ restoreEnvLocked() {
154
+ const restore = this.envRestore;
155
+ this.envRestore = undefined;
156
+ if (restore === undefined)
157
+ return;
158
+ try {
159
+ restore();
160
+ }
161
+ catch (error) {
162
+ this.safeWarn(`[dsh-perm-gate] proxy env restore failed: ${String(error)}`);
163
+ }
164
+ }
165
+ safeWarn(message) {
166
+ try {
167
+ this.options.logger.warn(message);
168
+ }
169
+ catch { /* never escalate */ }
170
+ }
171
+ safeInfo(message) {
172
+ const info = this.options.logger.info;
173
+ if (info === undefined)
174
+ return;
175
+ try {
176
+ info.call(this.options.logger, message);
177
+ }
178
+ catch { /* never escalate */ }
179
+ }
180
+ }