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.
Files changed (77) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +321 -0
  4. package/INSTALL.ja.md +70 -8
  5. package/INSTALL.ko.md +68 -9
  6. package/INSTALL.md +68 -8
  7. package/INSTALL.zh.md +59 -7
  8. package/README.ja.md +103 -16
  9. package/README.ko.md +101 -17
  10. package/README.md +152 -25
  11. package/README.zh.md +99 -12
  12. package/cordis.patch.yml +34 -3
  13. package/docs/baseline-guard.md +56 -0
  14. package/docs/plan-rules-to-settings.md +217 -0
  15. package/docs/rules-format.en.md +294 -0
  16. package/docs/rules-format.md +279 -0
  17. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  18. package/dsh.plugin.json +22 -0
  19. package/examples/permissions.example.yaml +16 -1
  20. package/lib/agent-identity.d.ts +43 -0
  21. package/lib/agent-identity.js +95 -0
  22. package/lib/allowlist.d.ts +27 -0
  23. package/lib/allowlist.js +103 -1
  24. package/lib/cli.js +11 -14
  25. package/lib/client.js +598 -6
  26. package/lib/client.js.map +1 -1
  27. package/lib/command-dispatcher.d.ts +22 -0
  28. package/lib/command-dispatcher.js +97 -0
  29. package/lib/command-semantics.d.ts +62 -0
  30. package/lib/command-semantics.js +6 -0
  31. package/lib/compiler.d.ts +50 -0
  32. package/lib/compiler.js +122 -0
  33. package/lib/config.d.ts +161 -7
  34. package/lib/config.js +78 -11
  35. package/lib/dry-run.d.ts +87 -0
  36. package/lib/dry-run.js +110 -0
  37. package/lib/engine.js +61 -4
  38. package/lib/evaluate.d.ts +9 -0
  39. package/lib/evaluate.js +264 -1
  40. package/lib/events.d.ts +69 -2
  41. package/lib/events.js +123 -0
  42. package/lib/index.d.ts +1 -1
  43. package/lib/index.js +320 -11
  44. package/lib/network-lifecycle.d.ts +107 -0
  45. package/lib/network-lifecycle.js +180 -0
  46. package/lib/network.d.ts +109 -0
  47. package/lib/network.js +196 -0
  48. package/lib/parsers/git.d.ts +43 -0
  49. package/lib/parsers/git.js +404 -0
  50. package/lib/parsers/shell-cmds.d.ts +32 -0
  51. package/lib/parsers/shell-cmds.js +293 -0
  52. package/lib/preset.d.ts +13 -0
  53. package/lib/preset.js +17 -0
  54. package/lib/proxy.d.ts +160 -0
  55. package/lib/proxy.js +529 -0
  56. package/lib/risk.d.ts +17 -3
  57. package/lib/risk.js +19 -6
  58. package/lib/rule-chain.d.ts +39 -0
  59. package/lib/rule-chain.js +213 -0
  60. package/lib/rule-dims.d.ts +174 -0
  61. package/lib/rule-dims.js +230 -0
  62. package/lib/rule.d.ts +60 -0
  63. package/lib/rule.js +69 -7
  64. package/lib/rules-view.d.ts +52 -0
  65. package/lib/rules-view.js +126 -0
  66. package/lib/runtime.d.ts +208 -7
  67. package/lib/runtime.js +433 -27
  68. package/lib/session-sweep.d.ts +45 -0
  69. package/lib/session-sweep.js +153 -0
  70. package/lib/shadow.d.ts +34 -0
  71. package/lib/shadow.js +143 -0
  72. package/lib/shell.d.ts +14 -0
  73. package/lib/shell.js +17 -0
  74. package/lib/watch.d.ts +42 -0
  75. package/lib/watch.js +170 -0
  76. package/package.json +15 -2
  77. package/scripts/patch-permission-glyph.mjs +209 -0
package/lib/index.js CHANGED
@@ -1,8 +1,16 @@
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 { parse } from 'yaml';
4
+ import { Config, RULES_NAMESPACE, RulesSchema, isRulesConfigured, readRulesFromSettings, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
5
+ import { appendAllowToSettings, replaceAllowInSettings } from './allowlist.js';
6
+ import { runDryRun } from './dry-run.js';
7
+ import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
4
8
  import { buildReceiverInfo } from './receiver-info.js';
9
+ import { readRulesView, readRulesViewFromSettings } from './rules-view.js';
5
10
  import { PermGateRuntime } from './runtime.js';
11
+ import { classifySessions, sweepSessionData } from './session-sweep.js';
12
+ import { decideNetworkTarget } from './network.js';
13
+ import { NetworkLifecycle } from './network-lifecycle.js';
6
14
  export const name = 'dsh-perm-gate';
7
15
  /**
8
16
  * The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
@@ -13,6 +21,8 @@ export const name = 'dsh-perm-gate';
13
21
  */
14
22
  export const inject = ['tools', 'webServer', 'llm', 'agentDefaultModel'];
15
23
  export { Config };
24
+ /** The closed approval-outcome vocabulary (anything else is normalized to `unavailable`). */
25
+ const APPROVAL_OUTCOMES = new Set(['allowed-once', 'rejected', 'cancelled', 'unavailable']);
16
26
  /** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
17
27
  export const PERMISSIVE_NAMESPACE = 'dsh-perm-gate';
18
28
  /**
@@ -137,10 +147,14 @@ export function makePreExecuteListener(runtime) {
137
147
  return async (exec, next) => {
138
148
  const decision = runtime.decideExecution(exec);
139
149
  // Passthrough (allow / stand-down) and a hard-deny need no grading.
140
- if (decision === undefined)
150
+ if (decision === undefined) {
151
+ // The call proceeds: register it so a network connection made by its
152
+ // child process can be attributed back to this session.
153
+ runtime.beginShellExecution(exec);
141
154
  return next();
155
+ }
142
156
  if (decision.kind !== 'ask')
143
- return decision;
157
+ return decision; // deny: it never runs
144
158
  // A cancelled call is never worth a model round-trip; the registry rechecks
145
159
  // cancellation after this gate settles.
146
160
  if (exec.signal?.aborted === true)
@@ -152,7 +166,15 @@ export function makePreExecuteListener(runtime) {
152
166
  catch {
153
167
  return decision; // fail-closed: keep the human ask
154
168
  }
155
- return refined ?? next();
169
+ if (refined === undefined) {
170
+ runtime.beginShellExecution(exec);
171
+ return next();
172
+ }
173
+ // Still an ask: the human decides. Register optimistically so a call they
174
+ // approve is attributable; a rejection is dropped by the abort/settle path.
175
+ if (refined.kind === 'ask')
176
+ runtime.beginShellExecution(exec);
177
+ return refined;
156
178
  };
157
179
  }
158
180
  export function apply(ctx, config = {}) {
@@ -163,6 +185,37 @@ export function apply(ctx, config = {}) {
163
185
  // read-only). The default resolves to `$DSH_HOME` / `~/.dsh`, so a profile
164
186
  // entry that omits `config` still records events, snapshots and learning.
165
187
  const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
188
+ // ─── Rules sources (dual-source: settings first, rulesFile fallback) ──
189
+ // The `dsh-perm-gate-rules` settings namespace is the primary rules store —
190
+ // the framework owns it, so the gate loads with no rules file on disk. A
191
+ // namespace still at bare defaults never shadows the file, and the first
192
+ // settings write seeds the file document along (implicit migration). Both
193
+ // references are filled in by the settings registration below; until then
194
+ // the file paths run exactly as before.
195
+ const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
196
+ let rulesFromSettings = () => ({});
197
+ const rulesScopeRef = { scope: undefined };
198
+ /** The raw file document, for seeding the namespace on its first write. */
199
+ const fileSeedDoc = () => {
200
+ try {
201
+ const text = readFileSync(rulesFilePath, 'utf8');
202
+ if (text.trim() === '')
203
+ return undefined;
204
+ return parse(text);
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ };
210
+ const settingsWrite = (write, fallback) => {
211
+ const scope = rulesScopeRef.scope;
212
+ if (scope === undefined)
213
+ return fallback();
214
+ void write(scope, fileSeedDoc()).catch((e) => {
215
+ console.warn('[dsh-perm-gate] settings rules write failed:', e);
216
+ });
217
+ return true;
218
+ };
166
219
  // Host model-group services (typed minimally; supplied by the dsh runtime
167
220
  // through the loader `inject` — dsh-approval-gate demonstrates the same
168
221
  // contract). The llm service backs the `host` receiver; agentDefaultModel
@@ -181,6 +234,28 @@ export function apply(ctx, config = {}) {
181
234
  ? llmService
182
235
  : undefined;
183
236
  const hostModelService = injected.agentDefaultModel ?? fallbackGet('agentDefaultModel');
237
+ // Host log channel, probed rather than assumed: cordis exposes `ctx.logger`,
238
+ // but the shape moved between DSH lines. Falls back to console so a warning
239
+ // is never silently dropped — and never throws, because these calls happen
240
+ // inside error handlers where a throw would escalate into a host crash.
241
+ const ctxLogger = (() => {
242
+ const candidate = ctx.logger;
243
+ if (candidate !== null && typeof candidate === 'object' && typeof candidate.warn === 'function') {
244
+ return candidate;
245
+ }
246
+ return undefined;
247
+ })();
248
+ const loggerWarn = (message) => {
249
+ try {
250
+ if (ctxLogger !== undefined)
251
+ ctxLogger.warn(message);
252
+ else
253
+ console.warn(message);
254
+ }
255
+ catch {
256
+ // A throwing logger must never become an unhandled error.
257
+ }
258
+ };
184
259
  // The live approval service, captured by the optional inject below. Its
185
260
  // `effectivePolicy` tells the gate whether an ask can reach a human at all.
186
261
  //
@@ -196,6 +271,32 @@ export function apply(ctx, config = {}) {
196
271
  const runtime = new PermGateRuntime({
197
272
  ...config,
198
273
  hostLlm,
274
+ // Raise a network approval through the DSH approval seam. The service
275
+ // applies the session policy, routes the prompt to the agent, and appends
276
+ // the `approval/asked`/`approval/decided` audit pair. Everything that is
277
+ // not an explicit `allowed-once` fails closed to `unavailable`, and the
278
+ // gate turns that into a block.
279
+ requestApproval: async (req) => {
280
+ const svc = approvalService;
281
+ if (svc === undefined || typeof svc.request !== 'function')
282
+ return 'unavailable';
283
+ try {
284
+ const outcome = await svc.request({
285
+ agent: req.agent,
286
+ toolName: req.toolName,
287
+ ...(req.callId !== undefined ? { callId: req.callId } : {}),
288
+ reason: req.reason,
289
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
290
+ });
291
+ return APPROVAL_OUTCOMES.has(String(outcome))
292
+ ? outcome
293
+ : 'unavailable';
294
+ }
295
+ catch {
296
+ // No open turn, a missing answerer, or a seam failure: never an allow.
297
+ return 'unavailable';
298
+ }
299
+ },
199
300
  // Read the Permissive tier live so the UI card's switches take effect on
200
301
  // the next tool call (no reload). Falls back to the composition entry.
201
302
  readPermissive: () => {
@@ -255,8 +356,19 @@ export function apply(ctx, config = {}) {
255
356
  : join(dataDir, 'learning.json'),
256
357
  // The rules document lives in the plugin's data dir by default, so a
257
358
  // `$DSH_HOME/perm-gate/rules.yml` the user writes is actually loaded without
258
- // also having to declare `rulesFile` in the composition entry.
259
- rulesFile: resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir),
359
+ // also having to declare `rulesFile` in the composition entry. It is the
360
+ // FALLBACK source: a configured `dsh-perm-gate-rules` namespace wins.
361
+ rulesFile: rulesFilePath,
362
+ // Settings-first rules: the live `dsh-perm-gate-rules` document when the
363
+ // namespace carries a real configuration, else undefined (file fallback).
364
+ readRulesDocument: () => readRulesFromSettings({ get: () => rulesFromSettings() }),
365
+ // Settings-backed allowlist writes; a falsy verdict (settings service
366
+ // absent) makes the runtime fall back to its own rules-file paths, and the
367
+ // namespace's watch triggers the reload once a write lands.
368
+ allowlistWriter: {
369
+ append: (pattern, reason) => settingsWrite((scope, seed) => appendAllowToSettings(scope, pattern, reason, seed), () => false),
370
+ replace: (patterns, reason) => settingsWrite((scope, seed) => replaceAllowInSettings(scope, patterns, reason, seed), () => false),
371
+ },
260
372
  // The whole gate is scoped to the presets that opt into it (default: the
261
373
  // `permissive` tier this plugin adds); elsewhere it stands down entirely.
262
374
  gatePresets: resolveGatePresets(Array.isArray(config.gatePresets) ? config.gatePresets : undefined),
@@ -290,29 +402,109 @@ export function apply(ctx, config = {}) {
290
402
  ? config.snapshotsDir
291
403
  : join(dataDir, 'snapshots'),
292
404
  });
405
+ // Session-lifecycle sweep: on startup and hourly, classify every session the
406
+ // gate holds data for against DSH's workspace store, and drop the
407
+ // authorization-chain data (decision events + pre-change snapshots) of
408
+ // sessions that were archived or no longer exist. The store is read-only
409
+ // here and every failure is fail-open (a skipped round retries in an hour);
410
+ // the timers are unref'd so cleanup never holds the process open.
411
+ if (config.sessionSweep !== false) {
412
+ const storeFile = typeof config.workspaceStoreFile === 'string' && config.workspaceStoreFile !== ''
413
+ ? config.workspaceStoreFile
414
+ : join(resolveDshHome(typeof config.dshHome === 'string' ? config.dshHome : undefined), 'storages', 'workspace.json');
415
+ const sweepEventsFile = typeof config.eventsFile === 'string' && config.eventsFile !== ''
416
+ ? config.eventsFile
417
+ : join(dataDir, 'events.jsonl');
418
+ const sweepSnapshotsDir = typeof config.snapshotsDir === 'string' && config.snapshotsDir !== ''
419
+ ? config.snapshotsDir
420
+ : join(dataDir, 'snapshots');
421
+ const sweepOnce = () => {
422
+ try {
423
+ const cls = classifySessions(readFileSync(storeFile, 'utf8'));
424
+ if (cls === null)
425
+ return; // torn read / format change — retry next round
426
+ sweepSessionData({ classification: cls, eventsFile: sweepEventsFile, snapshotsDir: sweepSnapshotsDir });
427
+ }
428
+ catch (e) {
429
+ console.warn('[dsh-perm-gate] session sweep skipped:', e instanceof Error ? e.message : e);
430
+ }
431
+ };
432
+ const first = setTimeout(sweepOnce, 0);
433
+ const hourly = setInterval(sweepOnce, 60 * 60 * 1000);
434
+ hourly.unref?.();
435
+ ctx.effect(() => () => {
436
+ clearTimeout(first);
437
+ clearInterval(hourly);
438
+ }, 'dsh-perm-gate: session sweep');
439
+ }
440
+ /**
441
+ * The network-relevant slice of a settings surface, as a comparison key.
442
+ * Used to decide whether a settings edit needs a proxy rebind at all — an
443
+ * unrelated edit (say, the allowlist) must not close and reopen the proxy.
444
+ */
445
+ const networkKeyOf = (source) => {
446
+ const s = (source ?? {});
447
+ return JSON.stringify([
448
+ s.networkEnabled, s.networkMode, s.networkUnlisted, s.networkLoopback,
449
+ s.networkBind, s.networkPort, s.networkNoProxy, s.networkInjectEnv,
450
+ ]);
451
+ };
452
+ // Forward references, resolved during apply before any UI edit can fire:
453
+ // the settings watch only ever runs on a later user action. Held in an
454
+ // object so the mutable slots stay lint-clean and the intent is explicit.
455
+ const networkRefs = {};
293
456
  installSettingsSection(ctx, PERMISSIVE_NAMESPACE, Config, config, {
294
457
  setSource: (source) => {
295
458
  current = source;
296
459
  },
297
460
  onChange: () => { },
298
461
  onScope: (scope) => {
299
- // Seed the editable whitelist from the rules file only when the namespace
300
- // carries no override yet, so the card shows the current allow patterns.
462
+ // Seed the editable whitelist from the rules source only when the
463
+ // namespace carries no override yet, so the card shows the current allow
464
+ // patterns.
301
465
  const surface = asSurface(scope.get());
302
466
  if (surface !== undefined && !Array.isArray(surface.allowlist)) {
303
467
  const patterns = runtime.allowlist();
304
468
  if (patterns.length > 0)
305
469
  void scope.update({ allowlist: patterns.slice() });
306
470
  }
307
- // Card edits -> namespace -> rulesFile (mirror + reload).
471
+ // Card edits -> namespace -> rules source (settings via the runtime's
472
+ // writer, else the rules file mirror) + reload.
308
473
  scope.watch(() => {
309
474
  const next = asSurface(scope.get());
310
475
  if (next !== undefined && Array.isArray(next.allowlist)) {
311
476
  runtime.setAllowlist(next.allowlist.filter((x) => typeof x === 'string'));
312
477
  }
478
+ // A network knob changed: re-mount the proxy so the card's switches
479
+ // take effect without a plugin reload. Only a rebind-relevant edit
480
+ // triggers it.
481
+ const key = networkKeyOf(next);
482
+ if (networkRefs.appliedKey !== undefined && networkRefs.appliedKey !== key) {
483
+ networkRefs.appliedKey = key;
484
+ networkRefs.rebind?.();
485
+ }
486
+ else if (networkRefs.appliedKey === undefined) {
487
+ networkRefs.appliedKey = key;
488
+ }
313
489
  });
314
490
  },
315
491
  });
492
+ // The rules namespace: the primary rules store (JSON twin of rules.yml).
493
+ // Every change recompiles the gate — the settings half of the hot reload
494
+ // that used to be a chokidar file watcher. `onScope` hands the live scope to
495
+ // the allowlist writers above; the seed entry is `{}`, so an untouched
496
+ // namespace reads as "not configured" and the file paths stay in force.
497
+ installSettingsSection(ctx, RULES_NAMESPACE, RulesSchema, {}, {
498
+ setSource: (source) => {
499
+ rulesFromSettings = source;
500
+ },
501
+ onChange: () => {
502
+ runtime.reload();
503
+ },
504
+ onScope: (scope) => {
505
+ rulesScopeRef.scope = scope;
506
+ },
507
+ });
316
508
  // Event feed HTTP API (best effort): the dsh webServer service exposes the
317
509
  // JSONL decision events to the browser half; without it events stay on disk.
318
510
  try {
@@ -352,6 +544,43 @@ export function apply(ctx, config = {}) {
352
544
  const offHealth = registerHealthRoute(webServer, { check: () => runtime.healthCheck() });
353
545
  if (offHealth !== undefined)
354
546
  ctx.effect(() => () => { offHealth(); }, 'dsh-perm-gate: health route');
547
+ // Network diagnostics: mode, bind, port, proxy liveness, env injection,
548
+ // block counters and recent blocks. Read-only.
549
+ const offNetwork = registerNetworkRoute(webServer, {
550
+ snapshot: () => networkRefs.snapshot?.() ?? { enabled: false, proxyActive: false },
551
+ });
552
+ if (offNetwork !== undefined)
553
+ ctx.effect(() => () => { offNetwork(); }, 'dsh-perm-gate: network route');
554
+ // Rule test (dry-run): evaluate one would-be call against the ruleset the
555
+ // gate currently has loaded, and report both the effective verdict and the
556
+ // rule layer's own answer. Read-only — the route has no write form, so
557
+ // testing a rule can never change it. It runs against the LIVE runtime
558
+ // rather than a fresh one so the panel tests the rules in force, not a
559
+ // re-derivation of them (a fresh runtime would also re-resolve the chain
560
+ // from a different root and could silently answer about a different file).
561
+ const offDryRun = registerDryRunRoute(webServer, {
562
+ run: (request) => runDryRun({
563
+ tool: request.tool,
564
+ args: request.args,
565
+ ...(request.permissive !== undefined ? { permissive: request.permissive } : {}),
566
+ }, runtime),
567
+ });
568
+ if (offDryRun !== undefined)
569
+ ctx.effect(() => () => { offDryRun(); }, 'dsh-perm-gate: dry-run route');
570
+ // Permissions view: the document the gate is actually loading, shown so
571
+ // the panel is never editing rules it cannot display. Read-only, and
572
+ // resolved exactly as the gate resolves it — settings first, the file the
573
+ // fallback resolves to otherwise.
574
+ const offRules = registerRulesRoute(webServer, {
575
+ view: () => {
576
+ const doc = rulesFromSettings();
577
+ if (isRulesConfigured(doc))
578
+ return readRulesViewFromSettings(doc);
579
+ return readRulesView(rulesFilePath);
580
+ },
581
+ });
582
+ if (offRules !== undefined)
583
+ ctx.effect(() => () => { offRules(); }, 'dsh-perm-gate: rules route');
355
584
  // Receiver projection for the settings card (provider/model catalog is
356
585
  // potentially slow to enumerate — cached briefly).
357
586
  let receiverCache = null;
@@ -398,7 +627,12 @@ export function apply(ctx, config = {}) {
398
627
  // Fallback terminal-answer channel: settle the ask from the call's result,
399
628
  // and settle a pending learning candidate (a result for one means the human
400
629
  // approved it and it executed — one confirmation).
401
- host.on('tools/result', ((exec, result) => { runtime.settleExecution(exec, result); }));
630
+ host.on('tools/result', ((exec, result) => {
631
+ // The call settled: it is no longer in flight, so a later connection must
632
+ // not be attributed to it.
633
+ runtime.endShellExecution(exec);
634
+ runtime.settleExecution(exec, result);
635
+ }));
402
636
  // Approval channel: answer an escalation the gate already cleared, and settle
403
637
  // the outcome of an ask the gate did raise. Registered `prepend` so it sits at
404
638
  // the head of the `approval/request` waterfall, ahead of the remote bridge that
@@ -422,5 +656,80 @@ export function apply(ctx, config = {}) {
422
656
  approvalService = undefined;
423
657
  }, 'dsh-perm-gate: approval answerer');
424
658
  });
659
+ // ─── Network proxy (Phase 2, T2.12 + rebind) ────────────────────────
660
+ // T2.10 escape hatch: default OFF. The network proxy is opt-in — enabling
661
+ // it binds a loopback port and rewrites proxy env vars, so it must never
662
+ // be turned on implicitly.
663
+ //
664
+ // The lifecycle lives in NetworkLifecycle: one persistent teardown effect,
665
+ // serialized (re)binds, and a diagnostics snapshot. A settings change calls
666
+ // rebind(), so the card's switches take effect without a plugin reload.
667
+ const readNetworkConfig = () => {
668
+ const live = current();
669
+ const str = (key, fallback) => typeof live[key] === 'string' && live[key] !== '' ? live[key] : fallback;
670
+ const num = (key, fallback) => typeof live[key] === 'number' ? live[key] : fallback;
671
+ const bool = (key, fallback) => typeof live[key] === 'boolean' ? live[key] : fallback;
672
+ return {
673
+ enabled: bool('networkEnabled', false),
674
+ mode: str('networkMode', 'whitelist'),
675
+ unlisted: str('networkUnlisted', 'ask'),
676
+ unattributed: str('networkUnattributed', 'allow'),
677
+ loopback: str('networkLoopback', 'allow'),
678
+ bind: str('networkBind', '127.0.0.1'),
679
+ port: num('networkPort', 0),
680
+ noProxy: str('networkNoProxy', 'clear'),
681
+ injectEnv: bool('networkInjectEnv', true),
682
+ askTimeoutMs: num('networkAskTimeoutMs', 120_000),
683
+ };
684
+ };
685
+ const networkLogger = ctxLogger !== undefined ? ctxLogger : { warn: (msg) => console.warn(msg) };
686
+ const networkLifecycle = new NetworkLifecycle({
687
+ effect: (disposeFactory, label) => { ctx.effect(disposeFactory, label); },
688
+ readConfig: readNetworkConfig,
689
+ decide: (target) => {
690
+ const cfg = readNetworkConfig();
691
+ return decideNetworkTarget(runtime.compiledRuleset, target, {
692
+ mode: cfg.mode,
693
+ unlisted: cfg.unlisted,
694
+ loopback: cfg.loopback,
695
+ // Attribution decides whether this connection is a shell subprocess
696
+ // (the gate's business) or DSH's own client (left alone by default).
697
+ attributed: runtime.currentAttribution() !== undefined,
698
+ unattributed: cfg.unattributed,
699
+ });
700
+ },
701
+ attribution: () => runtime.currentAttribution(),
702
+ // An `ask` verdict escalates to the interactive approval seam, raised on
703
+ // behalf of the shell command that opened the connection. A `deny` verdict
704
+ // never reaches here — approval widens reach but cannot override a rule.
705
+ escalate: async (target, decision) => {
706
+ const where = `${target.scheme ?? 'https'}://${target.host}${target.port !== undefined ? `:${target.port}` : ''}`;
707
+ const why = decision.matched && decision.ruleIndex !== undefined
708
+ ? `rule #${decision.ruleIndex + 1} asks about it`
709
+ : `${decision.mode} mode has no allow rule for it`;
710
+ 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);
711
+ },
712
+ logger: networkLogger,
713
+ });
714
+ // Never awaited at apply(): a slow or failing bind must not block plugin
715
+ // load. attach() degrades to "no proxy" internally.
716
+ void networkLifecycle.attach().catch((error) => {
717
+ loggerWarn(`[dsh-perm-gate] network proxy attach failed: ${String(error)}`);
718
+ });
719
+ // Resolve the forward reference and seed the change-detection key from the
720
+ // config actually applied, so the first UI edit is compared against it.
721
+ networkRefs.rebind = () => {
722
+ void networkLifecycle.rebind().catch((error) => {
723
+ loggerWarn(`[dsh-perm-gate] network proxy rebind failed: ${String(error)}`);
724
+ });
725
+ };
726
+ networkRefs.snapshot = () => networkLifecycle.snapshot();
727
+ if (networkRefs.appliedKey === undefined)
728
+ networkRefs.appliedKey = networkKeyOf(current());
729
+ // ─── Rule hot reload ────────────────────────────────────────────────
730
+ // The file watcher (chokidar) is gone: the settings namespace is the primary
731
+ // rules store and its `scope.watch` recompiles the gate on every change
732
+ // (see the RULES_NAMESPACE registration above). A rules FILE edit is no
733
+ // longer picked up mid-session — the settings document is the source to edit.
425
734
  return runtime;
426
735
  }
@@ -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
+ }