dsh-perm-gate 2.1.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 +84 -10
  7. package/README.ko.md +81 -10
  8. package/README.md +118 -22
  9. package/README.zh.md +86 -8
  10. package/cordis.patch.yml +34 -3
  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 +2 -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 +229 -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/runtime.d.ts CHANGED
@@ -6,6 +6,7 @@ import { RiskLearning } from './learning.js';
6
6
  import { type RiskRequest, type RiskVerdict } from './risk.js';
7
7
  import { type HostLlmLike, type HostModelSelection } from './host-llm.js';
8
8
  import { type CompiledRuleset } from './rule.js';
9
+ import type { NetworkTarget } from './network.js';
9
10
  import { type SessionEventLike } from './preset.js';
10
11
  export interface PreToolDecisionLike {
11
12
  kind: 'deny' | 'ask';
@@ -73,6 +74,8 @@ interface PendingAsk {
73
74
  readonly learningKey?: string;
74
75
  }
75
76
  export interface PermGateRuntimeOptions extends PermGateConfig {
77
+ /** Workspace root for chain resolution. Defaults to process.cwd(). */
78
+ readonly cwd?: string;
76
79
  readonly onRulesChanged?: (ruleset: CompiledRuleset) => void;
77
80
  readonly now?: () => number;
78
81
  /**
@@ -132,6 +135,24 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
132
135
  * review page's diff/revert data plane (events are still recorded).
133
136
  */
134
137
  readonly snapshotsDir?: string;
138
+ /**
139
+ * Raise one interactive approval request on behalf of a subprocess network
140
+ * connection. Wired by `apply` to the DSH `approval` service
141
+ * (`approval.request`), which routes the prompt to the agent, appends the
142
+ * `approval/asked` + `approval/decided` audit pair, and applies the session
143
+ * policy before any answerer runs.
144
+ *
145
+ * Absent (or throwing) means the connection cannot ride the seam and the
146
+ * gate fails closed to `deny`. Only `'allowed-once'` is treated as an allow.
147
+ */
148
+ readonly requestApproval?: (req: NetworkApprovalRequest) => Promise<ApprovalOutcomeLike>;
149
+ /**
150
+ * How long one approved network target stays approved for the session (ms).
151
+ * A single shell command routinely opens many connections to the same host;
152
+ * without this the human would be prompted once per connection.
153
+ * Default 30 minutes.
154
+ */
155
+ readonly networkGrantTtlMs?: number;
135
156
  }
136
157
  export type CallDecision = 'allow' | 'deny' | 'ask';
137
158
  /** The effective Permissive tier state read per tool call. */
@@ -152,6 +173,30 @@ export interface ApprovalRequestLike {
152
173
  }
153
174
  /** A closed approval outcome, structurally the DSH seam's `ApprovalOutcome`. */
154
175
  export type ApprovalOutcomeLike = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
176
+ /**
177
+ * One approval ask the gate raises on behalf of a subprocess network
178
+ * connection. Structurally the subset of the DSH `ApprovalRequest` the gate
179
+ * supplies — the host service adds the borrowed session/log handling.
180
+ */
181
+ export interface NetworkApprovalRequest {
182
+ /** The host agent the question is asked on behalf of (routes the prompt). */
183
+ readonly agent: unknown;
184
+ /** The tool whose subprocess opened the connection. */
185
+ readonly toolName: string;
186
+ /** The exact tool call, when the host gave one — lets the UI attach the prompt. */
187
+ readonly callId?: string;
188
+ readonly reason: string;
189
+ readonly signal?: AbortSignal;
190
+ }
191
+ /** The attribution the proxy layer receives for one connection. */
192
+ export interface ShellAttribution {
193
+ readonly tool: string;
194
+ readonly callId?: string;
195
+ /** The host agent, needed to route an approval request. */
196
+ readonly agent?: unknown;
197
+ readonly sessionId: string;
198
+ readonly signal?: AbortSignal;
199
+ }
155
200
  export declare class PermGateRuntime {
156
201
  private readonly options;
157
202
  private ruleset;
@@ -165,6 +210,14 @@ export declare class PermGateRuntime {
165
210
  private readonly gatePresets;
166
211
  /** Extra tool names the user classified as safe (config `autoAllowTools`). */
167
212
  private readonly autoAllowExtra;
213
+ /** In-flight shell executions, for proxy-layer network attribution. */
214
+ private readonly inFlightShells;
215
+ /** Stable key per execution object (the host call id is not always present). */
216
+ private readonly shellKeys;
217
+ private shellSeq;
218
+ /** Session-scoped network grants: grantKey -> expiry epoch ms. */
219
+ private readonly networkGrants;
220
+ private readonly networkGrantTtlMs;
168
221
  /**
169
222
  * Cache of the permission-preset fold. The key is the log's length plus the
170
223
  * identity of its LAST event: a session's log only appends, so "same length
@@ -213,6 +266,40 @@ export declare class PermGateRuntime {
213
266
  /** Deep read-only view of the learning state (diagnostics/tests). */
214
267
  learningSnapshot(): ReturnType<RiskLearning['snapshot']>;
215
268
  private ctxFor;
269
+ /**
270
+ * Register a shell execution as in-flight so a proxy-layer block made by its
271
+ * child process can be attributed back to the session — and therefore ride
272
+ * the interactive approval seam. Called when the gate lets a shell call run;
273
+ * {@link endShellExecution} removes it.
274
+ */
275
+ beginShellExecution(exec: ToolExecutionLike): void;
276
+ /** Remove a shell execution from the in-flight table (its tool call settled). */
277
+ endShellExecution(exec: ToolExecutionLike): void;
278
+ /** Drop every in-flight entry (dispose / session teardown). */
279
+ clearShellExecutions(): void;
280
+ /**
281
+ * The newest live in-flight shell execution, for proxy-layer attribution.
282
+ * Stale entries (a shell that never reported a result) are expired here so a
283
+ * long-dead call can never authorize a later connection.
284
+ */
285
+ currentAttribution(): ShellAttribution | undefined;
286
+ /** A stable per-execution key: the host call id, else an object-keyed fallback. */
287
+ private shellKeyOf;
288
+ /**
289
+ * Ask the human to allow one network target, on behalf of the attributed
290
+ * shell execution. Returns `'allow'` only for an explicit `allowed-once`;
291
+ * every other outcome (rejection, cancellation, timeout, no answerer)
292
+ * fails closed to `'deny'`.
293
+ *
294
+ * The approval seam requires an open turn — the durable audit pair must be
295
+ * enclosed by the log's commit boundary — which holds here because the ask
296
+ * happens *while* the shell tool call is executing.
297
+ */
298
+ askNetwork(target: NetworkTarget, reason: string, timeoutMs: number): Promise<'allow' | 'deny'>;
299
+ /** Record one network escalation outcome in the audit mirror + event feed. */
300
+ private auditNetworkAsk;
301
+ /** The compiled ruleset (read-only view for network module). */
302
+ get compiledRuleset(): CompiledRuleset;
216
303
  private liveRiskLearning;
217
304
  private liveRiskSediment;
218
305
  private liveClassifySource;
package/lib/runtime.js CHANGED
@@ -18,11 +18,13 @@ import { decideRules } from './evaluate.js';
18
18
  import { canonicalizeCall, GrantRegistry } from './grant.js';
19
19
  import { learnKey, operationFingerprint, RiskLearning } from './learning.js';
20
20
  import { ArtifactRegistry } from './path.js';
21
- import { decomposeShellCommand } from './shell.js';
22
21
  import { classifyRisk, classifyRiskWith } from './risk.js';
23
22
  import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
24
23
  import { chatCompletion } from './classifier.js';
25
24
  import { compileDocument, documentHash, extractPathCandidates, parsePermissionsDocument } from './rule.js';
25
+ import { decomposeShellCommand } from './shell.js';
26
+ import { extractAgentCandidates } from './agent-identity.js';
27
+ import { resolveRuleChain } from './rule-chain.js';
26
28
  import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
27
29
  import { permissionPresetOf, presetInScope } from './preset.js';
28
30
  import { resolveGatePresets } from './config.js';
@@ -164,6 +166,18 @@ function isCleanupSafe(exec) {
164
166
  }
165
167
  return undefined;
166
168
  }
169
+ /** A shell whose result never arrived is dropped after this long (ms). */
170
+ const IN_FLIGHT_SHELL_TTL_MS = 30 * 60_000;
171
+ /** Tool names that execute a command string (mirrors evaluate's shell roster). */
172
+ const SHELL_TOOL_NAMES = new Set(['shell', 'terminal', 'bash', 'pwsh', 'sh', 'cmd', 'powershell']);
173
+ /** Whether a tool name runs a shell command. */
174
+ function isShellToolName(name) {
175
+ return SHELL_TOOL_NAMES.has(name);
176
+ }
177
+ /** The session-grant key for one network target (scheme/host/port). */
178
+ function networkGrantKey(sessionId, target) {
179
+ return `${sessionId}|${target.scheme ?? ''}|${target.host}|${target.port ?? ''}`;
180
+ }
167
181
  export class PermGateRuntime {
168
182
  options;
169
183
  ruleset;
@@ -177,6 +191,15 @@ export class PermGateRuntime {
177
191
  gatePresets;
178
192
  /** Extra tool names the user classified as safe (config `autoAllowTools`). */
179
193
  autoAllowExtra;
194
+ // ─── Network seam (T2.11) ───────────────────────────────────────────
195
+ /** In-flight shell executions, for proxy-layer network attribution. */
196
+ inFlightShells = new Map();
197
+ /** Stable key per execution object (the host call id is not always present). */
198
+ shellKeys = new WeakMap();
199
+ shellSeq = 0;
200
+ /** Session-scoped network grants: grantKey -> expiry epoch ms. */
201
+ networkGrants = new Map();
202
+ networkGrantTtlMs;
180
203
  /**
181
204
  * Cache of the permission-preset fold. The key is the log's length plus the
182
205
  * identity of its LAST event: a session's log only appends, so "same length
@@ -213,6 +236,7 @@ export class PermGateRuntime {
213
236
  this.gatePresets = options.gatePresets === undefined ? ['*'] : resolveGatePresets(options.gatePresets);
214
237
  this.autoAllowExtra = new Set((options.autoAllowTools ?? []).filter((name) => typeof name === 'string' && name !== ''));
215
238
  this.strategies = resolvePermissiveStrategies(options.permissiveStrategies);
239
+ this.networkGrantTtlMs = options.networkGrantTtlMs ?? 30 * 60_000;
216
240
  this.learning = new RiskLearning(options.learningFile, {
217
241
  threshold: () => this.liveRiskLearning().threshold,
218
242
  now: options.now,
@@ -223,12 +247,33 @@ export class PermGateRuntime {
223
247
  this.ruleset = this.compileInline(options);
224
248
  }
225
249
  compileInline(options) {
226
- const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
227
250
  const empty = {
228
251
  defaultAction: options.defaultAction ?? 'ask',
229
252
  deny: [], allow: [], ask: [],
230
253
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
231
254
  };
255
+ // Chain mode: when searchUp is enabled, use the multi-file chain resolver.
256
+ if (options.searchUp === true) {
257
+ const cwd = options.cwd ?? process.cwd();
258
+ const rulesFile = options.rulesFile !== undefined ? options.rulesFile : 'rules.yml';
259
+ try {
260
+ return resolveRuleChain(cwd, {
261
+ rulesFile,
262
+ searchUp: true,
263
+ fallbackPath: options.fallbackPath,
264
+ badFilePolicy: options.badFilePolicy ?? 'fail',
265
+ maxChainLength: options.maxChainLength ?? 10,
266
+ }, {
267
+ maxGlobStars: 2,
268
+ caseInsensitivePaths: options.caseInsensitivePaths ?? true,
269
+ });
270
+ }
271
+ catch {
272
+ return empty;
273
+ }
274
+ }
275
+ // Single-file mode (legacy): read and compile one rules file.
276
+ const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
232
277
  if (text === '')
233
278
  return empty;
234
279
  const hash = documentHash(text);
@@ -307,8 +352,190 @@ export class PermGateRuntime {
307
352
  home: cwdOf(exec),
308
353
  caseInsensitive: this.options.caseInsensitivePaths ?? true,
309
354
  dshHome: this.options.dshHome,
355
+ agentCandidates: extractAgentCandidates(exec),
356
+ };
357
+ }
358
+ // ─── Network seam (T2.11) ───────────────────────────────────────────
359
+ /**
360
+ * Register a shell execution as in-flight so a proxy-layer block made by its
361
+ * child process can be attributed back to the session — and therefore ride
362
+ * the interactive approval seam. Called when the gate lets a shell call run;
363
+ * {@link endShellExecution} removes it.
364
+ */
365
+ beginShellExecution(exec) {
366
+ if (!isShellToolName(exec.name))
367
+ return;
368
+ const key = this.shellKeyOf(exec);
369
+ this.inFlightShells.set(key, {
370
+ key,
371
+ tool: exec.name,
372
+ callId: exec.callId,
373
+ agent: exec.agent,
374
+ sessionId: sessionIdOf(exec),
375
+ signal: exec.signal,
376
+ at: Date.now(),
377
+ });
378
+ }
379
+ /** Remove a shell execution from the in-flight table (its tool call settled). */
380
+ endShellExecution(exec) {
381
+ if (!isShellToolName(exec.name))
382
+ return;
383
+ this.inFlightShells.delete(this.shellKeyOf(exec));
384
+ }
385
+ /** Drop every in-flight entry (dispose / session teardown). */
386
+ clearShellExecutions() {
387
+ this.inFlightShells.clear();
388
+ }
389
+ /**
390
+ * The newest live in-flight shell execution, for proxy-layer attribution.
391
+ * Stale entries (a shell that never reported a result) are expired here so a
392
+ * long-dead call can never authorize a later connection.
393
+ */
394
+ currentAttribution() {
395
+ const now = Date.now();
396
+ let newest;
397
+ for (const entry of this.inFlightShells.values()) {
398
+ if (now - entry.at > IN_FLIGHT_SHELL_TTL_MS) {
399
+ this.inFlightShells.delete(entry.key);
400
+ continue;
401
+ }
402
+ // An aborted call was cancelled or rejected: it will never run, so it
403
+ // must not authorize a connection made by some later command.
404
+ if (entry.signal?.aborted === true) {
405
+ this.inFlightShells.delete(entry.key);
406
+ continue;
407
+ }
408
+ if (newest === undefined || entry.at >= newest.at)
409
+ newest = entry;
410
+ }
411
+ if (newest === undefined)
412
+ return undefined;
413
+ return {
414
+ tool: newest.tool,
415
+ ...(newest.callId !== undefined ? { callId: newest.callId } : {}),
416
+ ...(newest.agent !== undefined ? { agent: newest.agent } : {}),
417
+ sessionId: newest.sessionId,
418
+ ...(newest.signal !== undefined ? { signal: newest.signal } : {}),
310
419
  };
311
420
  }
421
+ /** A stable per-execution key: the host call id, else an object-keyed fallback. */
422
+ shellKeyOf(exec) {
423
+ const cached = this.shellKeys.get(exec);
424
+ if (cached !== undefined)
425
+ return cached;
426
+ const key = typeof exec.callId === 'string' && exec.callId !== ''
427
+ ? `call:${exec.callId}`
428
+ : `exec:${sessionIdOf(exec)}:${exec.name}:${++this.shellSeq}`;
429
+ this.shellKeys.set(exec, key);
430
+ return key;
431
+ }
432
+ /**
433
+ * Ask the human to allow one network target, on behalf of the attributed
434
+ * shell execution. Returns `'allow'` only for an explicit `allowed-once`;
435
+ * every other outcome (rejection, cancellation, timeout, no answerer)
436
+ * fails closed to `'deny'`.
437
+ *
438
+ * The approval seam requires an open turn — the durable audit pair must be
439
+ * enclosed by the log's commit boundary — which holds here because the ask
440
+ * happens *while* the shell tool call is executing.
441
+ */
442
+ async askNetwork(target, reason, timeoutMs) {
443
+ const attribution = this.currentAttribution();
444
+ if (attribution === undefined || attribution.agent === undefined) {
445
+ // Unattributable traffic cannot ride the approval seam — fail closed.
446
+ this.auditNetworkAsk(target, 'deny', 'no in-flight shell to attribute the connection to', undefined);
447
+ return 'deny';
448
+ }
449
+ // A session already allowed this exact target: honour it without asking.
450
+ const grantKey = networkGrantKey(attribution.sessionId, target);
451
+ if (this.networkGrants.has(grantKey)) {
452
+ return 'allow';
453
+ }
454
+ const requester = this.options.requestApproval;
455
+ if (requester === undefined) {
456
+ this.auditNetworkAsk(target, 'deny', 'no approval service available', attribution);
457
+ return 'deny';
458
+ }
459
+ const controller = new AbortController();
460
+ let timer;
461
+ // The timeout is raced, not merely signalled. `abort()` only *asks* the
462
+ // callee to withdraw the question; a callee that ignores the signal would
463
+ // otherwise leave this await pending forever and hang the CONNECT. The
464
+ // race guarantees the wait settles no matter what the seam does.
465
+ const timedOut = new Promise((resolve) => {
466
+ timer = setTimeout(() => {
467
+ controller.abort();
468
+ resolve('unavailable');
469
+ }, Math.max(1, timeoutMs));
470
+ });
471
+ let outcome = 'unavailable';
472
+ try {
473
+ outcome = await Promise.race([
474
+ requester({
475
+ agent: attribution.agent,
476
+ toolName: attribution.tool,
477
+ ...(attribution.callId !== undefined ? { callId: attribution.callId } : {}),
478
+ reason,
479
+ signal: controller.signal,
480
+ }),
481
+ timedOut,
482
+ ]);
483
+ }
484
+ catch {
485
+ // A seam failure (e.g. no open turn) is never an allow.
486
+ outcome = 'unavailable';
487
+ }
488
+ finally {
489
+ if (timer !== undefined)
490
+ clearTimeout(timer);
491
+ }
492
+ if (outcome === 'allowed-once') {
493
+ // Remember it for this session so one command making many connections
494
+ // does not prompt once per connection.
495
+ this.networkGrants.set(grantKey, Date.now() + this.networkGrantTtlMs);
496
+ this.auditNetworkAsk(target, 'allow', 'approved by the human (session grant)', attribution);
497
+ return 'allow';
498
+ }
499
+ this.auditNetworkAsk(target, 'deny', `approval seam returned ${outcome}`, attribution);
500
+ return 'deny';
501
+ }
502
+ /** Record one network escalation outcome in the audit mirror + event feed. */
503
+ auditNetworkAsk(target, verdict, reason, attribution) {
504
+ const now = Date.now();
505
+ const tool = attribution?.tool ?? 'subprocess';
506
+ const detail = `network ${verdict === 'allow' ? 'allowed' : 'denied'} ${target.scheme ?? '?'}://${target.host}${target.port !== undefined ? `:${target.port}` : ''} — ${reason}`;
507
+ try {
508
+ this.audit.append(makeEntry({
509
+ callId: randomId(),
510
+ tool,
511
+ outcome: verdict === 'allow' ? 'allow' : 'deny',
512
+ source: 'rule',
513
+ reason: detail,
514
+ at: now,
515
+ }));
516
+ }
517
+ catch {
518
+ // Auditing must never influence the verdict.
519
+ }
520
+ if (this.events !== undefined && attribution?.sessionId !== undefined) {
521
+ try {
522
+ this.events.append({
523
+ sessionId: attribution.sessionId,
524
+ tool,
525
+ kind: verdict === 'allow' ? 'auto' : 'deny',
526
+ reason: detail,
527
+ verdict: 'network-ask',
528
+ });
529
+ }
530
+ catch {
531
+ // Same: the feed is best-effort.
532
+ }
533
+ }
534
+ }
535
+ /** The compiled ruleset (read-only view for network module). */
536
+ get compiledRuleset() {
537
+ return this.ruleset;
538
+ }
312
539
  liveRiskLearning() {
313
540
  const read = this.options.readRiskLearning;
314
541
  if (read !== undefined)
@@ -0,0 +1,45 @@
1
+ /** Session ids grouped by their lifecycle class, all prefix-stripped (see {@link normalizeSessionId}). */
2
+ export interface SessionClassification {
3
+ /** Sessions referenced by some workspace's `sessionIds`. */
4
+ readonly live: ReadonlySet<string>;
5
+ /** Sessions listed in the store's `global.archivedSessionIds`. */
6
+ readonly archived: ReadonlySet<string>;
7
+ }
8
+ /** A session id whose gate data is stale and can be swept. */
9
+ export interface SweepReport {
10
+ /** Decision-event lines removed from events.jsonl. */
11
+ readonly eventsRemoved: number;
12
+ /** Snapshot files deleted from the snapshots directory. */
13
+ readonly snapshotsRemoved: number;
14
+ }
15
+ /** Strip the `session-` prefix so ids from different sources compare equal. */
16
+ export declare function normalizeSessionId(id: unknown): string;
17
+ /**
18
+ * Classify sessions from the raw text of the workspace store. Returns `null`
19
+ * on unparsable or structurally unexpected content (torn write, format bump) —
20
+ * the caller skips the round. Missing `global`/`tables` sections degrade to
21
+ * empty sets rather than failing: a fresh install has neither.
22
+ */
23
+ export declare function classifySessions(raw: string): SessionClassification | null;
24
+ /**
25
+ * Whether a gate-side session id belongs to an archived or deleted session.
26
+ * An empty id (unattributable legacy row) never matches — the same semantics
27
+ * `snapshotMatchesSession` uses for snapshots, so unattributable data is kept.
28
+ */
29
+ export declare function isStaleSession(id: string, cls: SessionClassification): boolean;
30
+ /** Options for {@link sweepSessionData}; all paths are plugin-owned except the store. */
31
+ export interface SweepOptions {
32
+ readonly classification: SessionClassification;
33
+ /** The decision-event JSONL feed (plugin data dir). */
34
+ readonly eventsFile: string;
35
+ /** The per-event pre-change snapshot directory (plugin data dir). */
36
+ readonly snapshotsDir: string;
37
+ }
38
+ /**
39
+ * Drop archived/dead sessions' rows from `events.jsonl` (atomic tmp+rename
40
+ * rewrite, only when something is actually removed) and delete their snapshot
41
+ * files. Both halves are independent: a failure in one still lets the other
42
+ * run. I/O errors are swallowed with a warn — best-effort cleanup, never a
43
+ * gating concern.
44
+ */
45
+ export declare function sweepSessionData(opts: SweepOptions): SweepReport;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Session-lifecycle sweep for dsh-perm-gate: DSH's workspace store
3
+ * (`<dshHome>/storages/workspace.json`) is the authority on which sessions are
4
+ * live and which were archived. On plugin start and hourly, the gate classifies
5
+ * every session id it holds gate data for — live (in a workspace), archived
6
+ * (in `global.archivedSessionIds`), or gone (neither) — and drops the
7
+ * authorization-chain data of archived/gone sessions: their decision events in
8
+ * `events.jsonl` and their pre-change snapshots in `snapshots/`.
9
+ *
10
+ * Fail-open like every other side effect in this plugin: any I/O or parse
11
+ * error is swallowed with a single warn and the round is skipped — the sweep
12
+ * must never influence gating. The workspace store is read-only here (DSH owns
13
+ * it and rewrites it continuously); a torn read simply defers to the next round.
14
+ */
15
+ import { readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ /** Strip the `session-` prefix so ids from different sources compare equal. */
18
+ export function normalizeSessionId(id) {
19
+ const s = String(id ?? '');
20
+ return s.startsWith('session-') ? s.slice('session-'.length) : s;
21
+ }
22
+ /**
23
+ * Classify sessions from the raw text of the workspace store. Returns `null`
24
+ * on unparsable or structurally unexpected content (torn write, format bump) —
25
+ * the caller skips the round. Missing `global`/`tables` sections degrade to
26
+ * empty sets rather than failing: a fresh install has neither.
27
+ */
28
+ export function classifySessions(raw) {
29
+ let doc;
30
+ try {
31
+ doc = JSON.parse(raw);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc))
37
+ return null;
38
+ const root = doc;
39
+ const live = new Set();
40
+ const workspaces = root.tables?.workspaces;
41
+ if (typeof workspaces === 'object' && workspaces !== null) {
42
+ for (const record of Object.values(workspaces)) {
43
+ const ids = record?.sessionIds;
44
+ if (!Array.isArray(ids))
45
+ continue;
46
+ for (const id of ids)
47
+ live.add(normalizeSessionId(id));
48
+ }
49
+ }
50
+ const archived = new Set();
51
+ if (Array.isArray(root.global?.archivedSessionIds)) {
52
+ for (const id of root.global.archivedSessionIds)
53
+ archived.add(normalizeSessionId(id));
54
+ }
55
+ return { live, archived };
56
+ }
57
+ /**
58
+ * Whether a gate-side session id belongs to an archived or deleted session.
59
+ * An empty id (unattributable legacy row) never matches — the same semantics
60
+ * `snapshotMatchesSession` uses for snapshots, so unattributable data is kept.
61
+ */
62
+ export function isStaleSession(id, cls) {
63
+ const norm = normalizeSessionId(id);
64
+ return norm !== '' && !cls.live.has(norm);
65
+ }
66
+ /**
67
+ * Drop archived/dead sessions' rows from `events.jsonl` (atomic tmp+rename
68
+ * rewrite, only when something is actually removed) and delete their snapshot
69
+ * files. Both halves are independent: a failure in one still lets the other
70
+ * run. I/O errors are swallowed with a warn — best-effort cleanup, never a
71
+ * gating concern.
72
+ */
73
+ export function sweepSessionData(opts) {
74
+ const eventsRemoved = sweepEvents(opts);
75
+ const snapshotsRemoved = sweepSnapshots(opts);
76
+ return { eventsRemoved, snapshotsRemoved };
77
+ }
78
+ function sweepEvents({ classification, eventsFile }) {
79
+ let raw;
80
+ try {
81
+ raw = readFileSync(eventsFile, 'utf8');
82
+ }
83
+ catch {
84
+ return 0; // no feed yet (or unreadable) — nothing to sweep
85
+ }
86
+ const lines = raw.split('\n');
87
+ const kept = [];
88
+ let removed = 0;
89
+ for (const line of lines) {
90
+ if (line.trim() === '')
91
+ continue;
92
+ let stale = false;
93
+ try {
94
+ const ev = JSON.parse(line);
95
+ stale = isStaleSession(String(ev.sessionId ?? ''), classification);
96
+ }
97
+ catch {
98
+ // An unparsable line is unattributable — keep it (never destroy evidence).
99
+ }
100
+ if (stale)
101
+ removed += 1;
102
+ else
103
+ kept.push(line);
104
+ }
105
+ if (removed === 0)
106
+ return 0;
107
+ try {
108
+ const tmp = `${eventsFile}.sweep-tmp`;
109
+ writeFileSync(tmp, kept.length > 0 ? kept.join('\n') + '\n' : '', 'utf8');
110
+ renameSync(tmp, eventsFile);
111
+ return removed;
112
+ }
113
+ catch (e) {
114
+ warn('events rewrite failed', e);
115
+ return 0;
116
+ }
117
+ }
118
+ function sweepSnapshots({ classification, snapshotsDir }) {
119
+ let entries;
120
+ try {
121
+ entries = readdirSync(snapshotsDir);
122
+ }
123
+ catch {
124
+ return 0; // no snapshot dir yet
125
+ }
126
+ let removed = 0;
127
+ for (const name of entries) {
128
+ if (!/^\d+\.json$/.test(name))
129
+ continue;
130
+ const abs = join(snapshotsDir, name);
131
+ let stale = false;
132
+ try {
133
+ const doc = JSON.parse(readFileSync(abs, 'utf8'));
134
+ stale = isStaleSession(String(doc.sessionId ?? ''), classification);
135
+ }
136
+ catch {
137
+ // Unparsable snapshot (torn write) has no attributable session — keep it.
138
+ }
139
+ if (!stale)
140
+ continue;
141
+ try {
142
+ rmSync(abs);
143
+ removed += 1;
144
+ }
145
+ catch (e) {
146
+ warn(`snapshot ${name} delete failed`, e);
147
+ }
148
+ }
149
+ return removed;
150
+ }
151
+ function warn(what, e) {
152
+ console.warn(`[dsh-perm-gate] session sweep: ${what}:`, e instanceof Error ? e.message : e);
153
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shadow detection for dsh-perm-gate rule chains.
3
+ *
4
+ * A rule is "shadowed" when an earlier rule in the same action partition
5
+ * always matches the same (or a superset of) tool calls, making the later
6
+ * rule unreachable. Shadowed rules are dead weight — they can never fire
7
+ * and may confuse users who think they are effective.
8
+ *
9
+ * This module performs a structural (conservative) shadow check. It does NOT
10
+ * do full semantic analysis — it flags potential shadows based on pattern
11
+ * structure, not on actual runtime argument values.
12
+ */
13
+ import type { CompiledRuleset } from './rule.js';
14
+ export interface ShadowReport {
15
+ /** Rule indices that are shadowed (unreachable). */
16
+ readonly shadowed: readonly number[];
17
+ /** Human-readable reasons per shadowed rule. */
18
+ readonly reasons: ReadonlyMap<number, string>;
19
+ }
20
+ /**
21
+ * Detect shadowed rules within a compiled ruleset.
22
+ *
23
+ * For each action partition (deny, allow, ask), check if any rule is
24
+ * dominated by an earlier rule in the same partition. A rule is dominated
25
+ * when:
26
+ * 1. Its tools pattern is a subset (or equal) of an earlier rule's tools
27
+ * 2. Its command pattern is a subset (or equal)
28
+ * 3. Its args/paths dimensions are subsets
29
+ * 4. Its new dimensions (params/absent/agents/when/network) are subsets
30
+ *
31
+ * This is conservative: it only flags rules that are DEFINITELY unreachable,
32
+ * not rules that MIGHT be unreachable under certain argument combinations.
33
+ */
34
+ export declare function detectShadows(ruleset: CompiledRuleset): ShadowReport;