dsh-perm-gate 2.0.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/INSTALL.ja.md +57 -1
  3. package/INSTALL.ko.md +54 -1
  4. package/INSTALL.md +53 -1
  5. package/INSTALL.zh.md +48 -1
  6. package/README.ja.md +86 -12
  7. package/README.ko.md +83 -12
  8. package/README.md +120 -24
  9. package/README.zh.md +88 -10
  10. package/cordis.patch.yml +34 -2
  11. package/dsh.plugin.json +22 -0
  12. package/lib/agent-identity.d.ts +43 -0
  13. package/lib/agent-identity.js +95 -0
  14. package/lib/client.js +114 -0
  15. package/lib/client.js.map +1 -1
  16. package/lib/compiler.d.ts +39 -0
  17. package/lib/compiler.js +106 -0
  18. package/lib/config.d.ts +95 -2
  19. package/lib/config.js +28 -3
  20. package/lib/engine.js +9 -2
  21. package/lib/evaluate.d.ts +9 -0
  22. package/lib/evaluate.js +189 -0
  23. package/lib/events.d.ts +13 -0
  24. package/lib/events.js +31 -0
  25. package/lib/index.d.ts +1 -1
  26. package/lib/index.js +235 -6
  27. package/lib/network-lifecycle.d.ts +107 -0
  28. package/lib/network-lifecycle.js +180 -0
  29. package/lib/network.d.ts +109 -0
  30. package/lib/network.js +196 -0
  31. package/lib/proxy.d.ts +160 -0
  32. package/lib/proxy.js +529 -0
  33. package/lib/risk.js +25 -3
  34. package/lib/rule-chain.d.ts +39 -0
  35. package/lib/rule-chain.js +222 -0
  36. package/lib/rule-dims.d.ts +139 -0
  37. package/lib/rule-dims.js +194 -0
  38. package/lib/rule.d.ts +25 -0
  39. package/lib/rule.js +15 -1
  40. package/lib/runtime.d.ts +87 -0
  41. package/lib/runtime.js +327 -2
  42. package/lib/session-sweep.d.ts +45 -0
  43. package/lib/session-sweep.js +153 -0
  44. package/lib/shadow.d.ts +34 -0
  45. package/lib/shadow.js +143 -0
  46. package/lib/watch.d.ts +42 -0
  47. package/lib/watch.js +170 -0
  48. package/package.json +8 -2
  49. package/scripts/patch-permission-glyph.mjs +209 -0
package/lib/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
@@ -22,6 +22,9 @@ import { classifyRisk, classifyRiskWith } from './risk.js';
22
22
  import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
23
23
  import { chatCompletion } from './classifier.js';
24
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';
25
28
  import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
26
29
  import { permissionPresetOf, presetInScope } from './preset.js';
27
30
  import { resolveGatePresets } from './config.js';
@@ -99,6 +102,82 @@ const CLEARED_CALL_TTL_MS = 5 * 60_000;
99
102
  * unrecognized wording fails closed (the request is forwarded to the human).
100
103
  */
101
104
  const SANDBOX_ESCALATION_REASON = /^escalate sandbox to (workspace-write|danger-full-access): /;
105
+ // --- Deterministic cleanup pre-screen ----------------------------------------
106
+ // Pattern for shell deletion commands (rm, rmdir, Remove-Item) with flags.
107
+ const SHELL_DELETE_RE = /^(?:rm|rmdir|Remove-Item)\b/i;
108
+ // Temp/build artifact directory name patterns (case-insensitive match on each path segment).
109
+ const REGENERABLE_SEGMENT = /(?:^|[\\/])(?:temp|tmp|test[-_]clone|node_modules|\.cache|dist|build|__pycache__|\.pytest_cache|\.git|\.next|\.nuxt|coverage|\.tox|\.venv|venv|env|\.mypy_cache|\.parcel-cache)(?:[\\/]|$)/i;
110
+ // Workspace-relative path tokens that are always regenerable.
111
+ const REGENERABLE_TOKENS = /^(?:node_modules|\.next|__pycache__|\.cache|dist|build|\.git|coverage|\.pytest_cache|\.tox|\.venv|venv|env)$/i;
112
+ /**
113
+ * Deterministic pre-screen for deletion commands. Returns a reason string when
114
+ * the deletion is clearly safe (targeting a regenerable/temp artifact inside the
115
+ * workspace), or undefined when the LLM should decide.
116
+ *
117
+ * This runs BEFORE the LLM risk grader to avoid the LLM's unconditional
118
+ * "Remove-Item → risky:deletion" bias. The LLM is still the fallback for
119
+ * ambiguous cases.
120
+ */
121
+ function isCleanupSafe(exec) {
122
+ const tool = exec.name;
123
+ const args = exec.arguments ?? {};
124
+ // Only applies to shell/pwsh tools.
125
+ if (!/^(?:pwsh|bash|sh|cmd|shell|terminal)$/i.test(tool))
126
+ return undefined;
127
+ const commandText = typeof args.command === 'string' ? args.command : undefined;
128
+ if (commandText === undefined)
129
+ return undefined;
130
+ let commands = [];
131
+ try {
132
+ commands = decomposeShellCommand(commandText).commands;
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ const cwd = typeof args.cwd === 'string' ? args.cwd : cwdOf(exec) ?? '';
138
+ const posixCwd = cwd.replace(/\\/g, '/').toLowerCase();
139
+ for (const cmd of commands) {
140
+ if (!SHELL_DELETE_RE.test(cmd.command))
141
+ continue;
142
+ // Collect all non-flag argument tokens as potential targets.
143
+ const targets = cmd.args.filter((a) => !a.startsWith('-'));
144
+ if (targets.length === 0)
145
+ continue;
146
+ let allSafe = true;
147
+ for (const target of targets) {
148
+ const posixTarget = target.replace(/\\/g, '/').toLowerCase();
149
+ // Resolve relative to cwd.
150
+ const fullPath = posixTarget.startsWith('/') || /^[a-z]:\//i.test(posixTarget)
151
+ ? posixTarget
152
+ : posixCwd !== '' ? `${posixCwd}/${posixTarget}` : posixTarget;
153
+ // Check if the target path contains a regenerable segment.
154
+ if (REGENERABLE_SEGMENT.test(fullPath))
155
+ continue;
156
+ // Check bare token (e.g. "node_modules" without path separators).
157
+ if (REGENERABLE_TOKENS.test(target))
158
+ continue;
159
+ // Not clearly regenerable — let the LLM decide.
160
+ allSafe = false;
161
+ break;
162
+ }
163
+ if (allSafe) {
164
+ return `deletion targets regenerable/temp artifacts: ${targets.join(', ')}`;
165
+ }
166
+ }
167
+ return undefined;
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
+ }
102
181
  export class PermGateRuntime {
103
182
  options;
104
183
  ruleset;
@@ -112,6 +191,15 @@ export class PermGateRuntime {
112
191
  gatePresets;
113
192
  /** Extra tool names the user classified as safe (config `autoAllowTools`). */
114
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;
115
203
  /**
116
204
  * Cache of the permission-preset fold. The key is the log's length plus the
117
205
  * identity of its LAST event: a session's log only appends, so "same length
@@ -148,6 +236,7 @@ export class PermGateRuntime {
148
236
  this.gatePresets = options.gatePresets === undefined ? ['*'] : resolveGatePresets(options.gatePresets);
149
237
  this.autoAllowExtra = new Set((options.autoAllowTools ?? []).filter((name) => typeof name === 'string' && name !== ''));
150
238
  this.strategies = resolvePermissiveStrategies(options.permissiveStrategies);
239
+ this.networkGrantTtlMs = options.networkGrantTtlMs ?? 30 * 60_000;
151
240
  this.learning = new RiskLearning(options.learningFile, {
152
241
  threshold: () => this.liveRiskLearning().threshold,
153
242
  now: options.now,
@@ -158,12 +247,33 @@ export class PermGateRuntime {
158
247
  this.ruleset = this.compileInline(options);
159
248
  }
160
249
  compileInline(options) {
161
- const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
162
250
  const empty = {
163
251
  defaultAction: options.defaultAction ?? 'ask',
164
252
  deny: [], allow: [], ask: [],
165
253
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
166
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) : '';
167
277
  if (text === '')
168
278
  return empty;
169
279
  const hash = documentHash(text);
@@ -242,8 +352,190 @@ export class PermGateRuntime {
242
352
  home: cwdOf(exec),
243
353
  caseInsensitive: this.options.caseInsensitivePaths ?? true,
244
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 } : {}),
245
419
  };
246
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
+ }
247
539
  liveRiskLearning() {
248
540
  const read = this.options.readRiskLearning;
249
541
  if (read !== undefined)
@@ -538,6 +830,19 @@ export class PermGateRuntime {
538
830
  };
539
831
  const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
540
832
  const decision = this.applyPermissive(raw);
833
+ // Deterministic cleanup pre-screen: if a deletion command targets a
834
+ // regenerable/temporary artifact inside the workspace, allow it directly.
835
+ // This runs BEFORE the ask/passthrough logic so it works even under
836
+ // `approval: never` (where refineAsk is never called).
837
+ if (decision.action === 'ask') {
838
+ const cleanupSafe = isCleanupSafe(exec);
839
+ if (cleanupSafe !== undefined) {
840
+ const reason = `cleanup-safe: ${cleanupSafe}`;
841
+ this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
842
+ this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'cleanup-safe', category: 'safe' });
843
+ return undefined; // passthrough (allow)
844
+ }
845
+ }
541
846
  let outcome;
542
847
  let source;
543
848
  switch (decision.stage) {
@@ -644,6 +949,19 @@ export class PermGateRuntime {
644
949
  return undefined;
645
950
  }
646
951
  }
952
+ // Defensive cleanup pre-screen: normally this is handled in decideExecution
953
+ // before refineAsk is ever called, but refineAsk is public and may be called
954
+ // directly by tests or future embedders. Keep the same deterministic check
955
+ // here as a safety net — identical logic to decideExecution's pre-screen.
956
+ const cleanupSafe = isCleanupSafe(exec);
957
+ if (cleanupSafe !== undefined) {
958
+ const reason = `cleanup-safe: ${cleanupSafe}`;
959
+ this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
960
+ this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'cleanup-safe', category: 'safe' });
961
+ this.untrackAsk(exec);
962
+ this.clearCall(exec, reason, 'cleanup-safe', 'classifier');
963
+ return undefined;
964
+ }
647
965
  if (!permissive.strategies.llmAssist)
648
966
  return decision;
649
967
  // One live read of the classifier setup. Without a usable receiver (a
@@ -656,7 +974,14 @@ export class PermGateRuntime {
656
974
  && (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
657
975
  return decision;
658
976
  }
659
- const req = { tool: exec.name, args: exec.arguments ?? {}, reason: decision.reason };
977
+ // Inject cwd into the risk request so the LLM can see the full path context
978
+ // (e.g. that a Remove-Item targets a temp directory, not user data).
979
+ const riskArgs = { ...(exec.arguments ?? {}) };
980
+ const cwd = cwdOf(exec);
981
+ if (typeof cwd === 'string' && cwd !== '' && typeof riskArgs.cwd !== 'string') {
982
+ riskArgs.cwd = cwd;
983
+ }
984
+ const req = { tool: exec.name, args: riskArgs, reason: decision.reason };
660
985
  let risk;
661
986
  if (this.options.riskHook !== undefined) {
662
987
  risk = await this.options.riskHook(req);
@@ -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;