dsh-perm-gate 2.1.0 → 2.6.1
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 +108 -0
- package/CHANGELOG.ko.md +104 -0
- package/CHANGELOG.md +347 -0
- package/INSTALL.ja.md +84 -8
- package/INSTALL.ko.md +80 -9
- package/INSTALL.md +83 -8
- package/INSTALL.zh.md +70 -7
- package/README.ja.md +130 -16
- package/README.ko.md +128 -17
- package/README.md +179 -25
- package/README.zh.md +121 -12
- package/assets/allow1.png +0 -0
- package/assets/allow2.png +0 -0
- package/assets/gateoff.png +0 -0
- package/assets/llms.png +0 -0
- package/assets/permlist.png +0 -0
- package/assets/setting-head.png +0 -0
- package/assets/setting-rules.png +0 -0
- package/cordis.patch.yml +37 -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 +26 -3
- package/scripts/patch-permission-glyph.mjs +392 -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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
300
|
-
// carries no override yet, so the card shows the current allow
|
|
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 ->
|
|
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) => {
|
|
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
|
+
}
|