dsh-perm-gate 0.2.0 → 0.2.1-beta.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.
package/lib/runtime.js CHANGED
@@ -12,14 +12,33 @@ import { readFileSync } from 'node:fs';
12
12
  import { makeEntry, MemoryAuditMirror } from './audit.js';
13
13
  import { resolvePermissiveStrategies } from './config.js';
14
14
  import { appendAllowCommand, listAllowCommands, replaceAllowCommands } from './allowlist.js';
15
- import { decide } from './engine.js';
16
- import { EventLog } from './events.js';
15
+ import { decide, CONTENT_ARG_KEYS } from './engine.js';
16
+ import { EventLog, saveEventSnapshots } from './events.js';
17
17
  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 { classifyRisk } from './risk.js';
22
- import { compileDocument, documentHash, parsePermissionsDocument } from './rule.js';
21
+ import { classifyRisk, classifyRiskWith } from './risk.js';
22
+ import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
23
+ import { chatCompletion } from './classifier.js';
24
+ import { compileDocument, documentHash, extractPathCandidates, parsePermissionsDocument } from './rule.js';
25
+ import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
26
+ import { permissionPresetOf, presetInScope } from './preset.js';
27
+ import { resolveGatePresets } from './config.js';
28
+ /** The session id of one execution (direct field, else the agent's session). */
29
+ function sessionIdOf(exec) {
30
+ if (typeof exec.sessionId === 'string' && exec.sessionId !== '')
31
+ return exec.sessionId;
32
+ const id = exec.agent?.session?.id;
33
+ return typeof id === 'string' ? id : '';
34
+ }
35
+ /** The workspace cwd of one execution (direct field, else the session header). */
36
+ function cwdOf(exec) {
37
+ if (typeof exec.cwd === 'string' && exec.cwd !== '')
38
+ return exec.cwd;
39
+ const cwd = exec.agent?.session?.header?.cwd;
40
+ return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
41
+ }
23
42
  /** Upper bound on in-flight learning candidates awaiting `tools/result`. */
24
43
  const PENDING_CAP = 100;
25
44
  export class PermGateRuntime {
@@ -31,20 +50,40 @@ export class PermGateRuntime {
31
50
  cache = new Map(); // hash -> compiled
32
51
  permissiveEnabled;
33
52
  strategies;
53
+ /** Presets in which this gate is active at all; outside them it stands down. */
54
+ gatePresets;
55
+ /** Extra tool names the user classified as safe (config `autoAllowTools`). */
56
+ autoAllowExtra;
57
+ /** Cache of the permission-preset fold, keyed by log identity + length. */
58
+ presetCache;
34
59
  learning;
35
60
  events;
36
61
  /** Learning candidates awaiting human approval + execution (call fingerprint → candidate). */
37
62
  pending = new Map();
63
+ /**
64
+ * Asks awaiting the human's terminal answer, keyed by {@link askKey}. Both the
65
+ * approval observer (primary) and `tools/result` (fallback) settle an entry by
66
+ * deleting it — "delete on settle" is the whole de-duplication rule, so a
67
+ * missing `callId` or an untriggered observer can never lose a record.
68
+ */
69
+ pendingAsks = new Map();
38
70
  constructor(options = {}) {
39
71
  this.options = options;
40
72
  this.grants = new GrantRegistry('__session__', options.now);
41
73
  this.permissiveEnabled = options.permissive ?? false;
74
+ // An embedding that never states a scope keeps the historical behaviour
75
+ // (the gate acts in every preset); the plugin always passes the resolved
76
+ // scope from `resolveConfig`, whose product default is `['permissive']`.
77
+ this.gatePresets = options.gatePresets === undefined ? ['*'] : resolveGatePresets(options.gatePresets);
78
+ this.autoAllowExtra = new Set((options.autoAllowTools ?? []).filter((name) => typeof name === 'string' && name !== ''));
42
79
  this.strategies = resolvePermissiveStrategies(options.permissiveStrategies);
43
80
  this.learning = new RiskLearning(options.learningFile, {
44
81
  threshold: () => this.liveRiskLearning().threshold,
45
82
  now: options.now,
46
83
  });
47
- this.events = options.eventsFile !== undefined ? new EventLog(options.eventsFile, options.now) : undefined;
84
+ this.events = options.eventsFile !== undefined
85
+ ? new EventLog(options.eventsFile, options.now, options.snapshotsDir)
86
+ : undefined;
48
87
  this.ruleset = this.compileInline(options);
49
88
  }
50
89
  compileInline(options) {
@@ -107,6 +146,10 @@ export class PermGateRuntime {
107
146
  get eventLog() {
108
147
  return this.events;
109
148
  }
149
+ /** The snapshot directory backing diff/revert (undefined when disabled). */
150
+ get snapshotDir() {
151
+ return this.options.snapshotsDir;
152
+ }
110
153
  /** Learning candidates currently awaiting settlement (diagnostics/tests). */
111
154
  pendingCount() {
112
155
  return this.pending.size;
@@ -116,11 +159,16 @@ export class PermGateRuntime {
116
159
  return this.learning.snapshot();
117
160
  }
118
161
  ctxFor(exec) {
162
+ const cwd = cwdOf(exec) ?? process.cwd();
119
163
  return {
120
164
  tool: exec.name,
121
165
  args: exec.arguments ?? {},
122
166
  commandText: exec.commandText ?? (typeof exec.arguments.command === 'string' ? exec.arguments.command : undefined),
123
- cwd: exec.cwd ?? process.cwd(),
167
+ cwd,
168
+ // The workspace root (the session's cwd). The write-path override in
169
+ // `decideRules` uses this to determine whether a write/edit target is
170
+ // inside the workspace or outside it.
171
+ home: cwdOf(exec),
124
172
  caseInsensitive: this.options.caseInsensitivePaths ?? true,
125
173
  dshHome: this.options.dshHome,
126
174
  };
@@ -131,13 +179,169 @@ export class PermGateRuntime {
131
179
  return read();
132
180
  return { enabled: this.options.riskLearning ?? false, threshold: this.options.riskThreshold ?? 3 };
133
181
  }
182
+ liveRiskSediment() {
183
+ const read = this.options.readRiskSediment;
184
+ return read !== undefined ? read() : (this.options.riskSediment ?? true);
185
+ }
186
+ liveClassifySource() {
187
+ const read = this.options.readClassifySource;
188
+ return read !== undefined ? read() : 'custom';
189
+ }
190
+ /** The host model-group selection, or undefined when unusable. */
191
+ hostModel() {
192
+ const sel = this.options.readHostModel?.();
193
+ return sel !== undefined && sel.provider !== '' && sel.model !== '' ? sel : undefined;
194
+ }
134
195
  livePermissive() {
135
196
  return this.options.readPermissive !== undefined
136
197
  ? this.options.readPermissive()
137
198
  : { enabled: this.permissiveEnabled, strategies: this.strategies };
138
199
  }
139
- recordEvent(exec, kind, reason, risk) {
140
- this.events?.append({ sessionId: exec.sessionId, tool: exec.name, kind, risk, reason });
200
+ /**
201
+ * The session's selected permission preset, folded from its event log. The
202
+ * fold is cached by the log array identity plus its length: a session's log
203
+ * is a stable, append-only array, so a changed reference or a grown log
204
+ * invalidates the cache while repeated calls on the same log stay cheap.
205
+ */
206
+ presetOf(exec) {
207
+ const events = exec.agent?.session?.events;
208
+ const length = events?.length ?? -1;
209
+ const cached = this.presetCache;
210
+ if (cached !== undefined && cached.events === events && cached.length === length)
211
+ return cached.preset;
212
+ const preset = permissionPresetOf(events);
213
+ this.presetCache = { events, length, preset };
214
+ return preset;
215
+ }
216
+ /**
217
+ * Whether this gate is active for one call at all.
218
+ *
219
+ * The gate owns an independent permission tier, so it only acts while that
220
+ * tier (or another preset the user listed in `gatePresets`) is selected.
221
+ * Outside it the gate stands down completely — no allow, no ask, no deny, no
222
+ * P0 hard-deny — because the selected tier's own policy governs the session:
223
+ * `danger-full-access` means "full access without approval prompts", where a
224
+ * forwarded ask can only fail (the approval seam rejects before any answerer
225
+ * runs) and a hard-deny would silently overrule the tier the user chose.
226
+ */
227
+ gateActive(exec) {
228
+ return presetInScope(this.presetOf(exec), this.gatePresets);
229
+ }
230
+ /**
231
+ * Whether an `ask` produced while the gate is active can actually reach a
232
+ * human. Under `approval: never` the DSH approval seam returns `rejected`
233
+ * before any answerer runs, so such an ask degrades to passthrough.
234
+ *
235
+ * The reader is an optional duck-typed seam (the host half probes a PRIVATE
236
+ * approval-service method, identical in DSH 0.1.1-rc.2 and 0.1.2-rc.1). A
237
+ * throwing reader must not kill the decision path: "unknown" is treated as
238
+ * answerable, so the ask stands and the approval seam decides.
239
+ */
240
+ askAnswerable(exec) {
241
+ try {
242
+ return this.options.readApprovalPolicy?.(exec) !== 'never';
243
+ }
244
+ catch {
245
+ return true;
246
+ }
247
+ }
248
+ /** Effective deny-keyword blacklist: the live namespace override or the preset.
249
+ * An empty override is treated as "no meaningful override" (the blacklist is
250
+ * protective and stays on); schemastery materializes unset arrays as `[]`. */
251
+ liveDenyKeywords() {
252
+ const read = this.options.readDenyKeywords;
253
+ const override = read !== undefined ? read() : undefined;
254
+ return Array.isArray(override) && override.length > 0 ? override : DEFAULT_DENY_KEYWORDS;
255
+ }
256
+ /**
257
+ * The preset deny-keyword layer (inherited from dsh-approval-gate): a keyword
258
+ * hit over the call's command/argument text vetoes it with `deny` before any
259
+ * allow path (deny wins over allow). Purely additive to P0 — it can only ever
260
+ * deny, never widen. Matching is boundary-aware and skips document bodies, so
261
+ * a keyword can no longer veto an unrelated identifier or a file's text.
262
+ */
263
+ denyKeywordHit(exec) {
264
+ const keywords = this.liveDenyKeywords();
265
+ if (keywords.length === 0)
266
+ return undefined;
267
+ const text = denyScanText(exec);
268
+ if (text === '')
269
+ return undefined;
270
+ for (const keyword of keywords) {
271
+ if (String(keyword).trim() === '')
272
+ continue;
273
+ if (keywordMatcher(String(keyword)).test(text))
274
+ return keyword;
275
+ }
276
+ return undefined;
277
+ }
278
+ /**
279
+ * Record one decision event on the feed. `verdict` labels the decision path
280
+ * for the history view; `files` (defaulted from the call itself) drives the
281
+ * snapshot/diff/revert chain, and `baseDir` resolves relative paths.
282
+ */
283
+ recordEvent(exec, kind, reason, extra = {}) {
284
+ this.events?.append({
285
+ sessionId: sessionIdOf(exec),
286
+ tool: exec.name,
287
+ kind,
288
+ risk: extra.risk,
289
+ reason,
290
+ verdict: extra.verdict,
291
+ justification: extra.justification ?? reason,
292
+ category: extra.category,
293
+ files: extra.files ?? eventFiles(exec),
294
+ learningCount: extra.learningCount,
295
+ threshold: extra.threshold,
296
+ baseDir: cwdOf(exec),
297
+ });
298
+ }
299
+ /** The correlation key of one ask: its call id, or a canonical call fingerprint. */
300
+ askKey(exec) {
301
+ return typeof exec.callId === 'string' && exec.callId !== ''
302
+ ? `id:${exec.callId}`
303
+ : `fp:${canonicalizeCall(exec.name, exec.arguments ?? {})}`;
304
+ }
305
+ /** Remember one ask so either settlement channel can record its terminal answer. */
306
+ trackAsk(exec, reason, learningKey) {
307
+ if (this.pendingAsks.size >= PENDING_CAP) {
308
+ const oldest = this.pendingAsks.keys().next();
309
+ if (!oldest.done)
310
+ this.pendingAsks.delete(oldest.value);
311
+ }
312
+ // Generate a synthetic snapshot ID since we no longer record 'ask' events
313
+ // (they'd pollute the approval record). Snapshots are keyed by this ID.
314
+ const now = typeof this.options.now === 'function' ? this.options.now : Date.now;
315
+ const snapshotId = now();
316
+ const pending = {
317
+ reason,
318
+ tool: exec.name,
319
+ sessionId: sessionIdOf(exec),
320
+ files: eventFiles(exec),
321
+ baseDir: cwdOf(exec),
322
+ snapshotId,
323
+ learningKey,
324
+ };
325
+ this.pendingAsks.set(this.askKey(exec), pending);
326
+ // Save pre-change snapshots so the review page can diff/revert later.
327
+ if (pending.files.length > 0 && this.snapshotDir) {
328
+ try {
329
+ saveEventSnapshots(this.snapshotDir, snapshotId, pending.files, pending.baseDir, pending.sessionId, now);
330
+ }
331
+ catch { /* best-effort */ }
332
+ }
333
+ }
334
+ /** Drop one tracked ask without recording it (the call never needed a human). */
335
+ untrackAsk(exec) {
336
+ this.pendingAsks.delete(this.askKey(exec));
337
+ }
338
+ /** Asks currently awaiting a terminal answer (diagnostics/tests). */
339
+ pendingAskCount() {
340
+ return this.pendingAsks.size;
341
+ }
342
+ /** Get one pending ask by its key (diagnostics/tests only). */
343
+ getPendingAsk(key) {
344
+ return this.pendingAsks.get(key);
141
345
  }
142
346
  /**
143
347
  * Decide one tool call. Returns a decision to veto (`deny`/`ask`) or
@@ -149,14 +353,28 @@ export class PermGateRuntime {
149
353
  * the human seam.
150
354
  */
151
355
  decideExecution(exec) {
356
+ // The gate is scoped to its own tier(s): anywhere else it stands down
357
+ // entirely and records nothing (the session's selected tier owns the call).
358
+ if (!this.gateActive(exec))
359
+ return undefined;
152
360
  const ctx = this.ctxFor(exec);
153
361
  const callId = randomId();
362
+ // Preset deny-keyword layer (deny wins over allow): a dangerous-keyword hit
363
+ // vetoes before grants / rules / LLM. Additive deny only — P0 semantics are
364
+ // untouched and every later stage stays behind this veto.
365
+ const keyword = this.denyKeywordHit(exec);
366
+ if (keyword !== undefined) {
367
+ const reason = `deny-keyword: matches preset blacklist entry "${keyword}"`;
368
+ this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'deny', source: 'deny-keyword', reason, at: Date.now() }));
369
+ this.recordEvent(exec, 'deny', reason, { verdict: 'deny-keyword' });
370
+ return { kind: 'deny', reason };
371
+ }
154
372
  const grantResolver = (tool, args) => {
155
373
  if (exec.parentAuthorized === false)
156
374
  return 'no-match';
157
375
  return this.grants.decide(tool, args);
158
376
  };
159
- const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver);
377
+ const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
160
378
  const decision = this.applyPermissive(raw);
161
379
  let outcome;
162
380
  let source;
@@ -183,12 +401,30 @@ export class PermGateRuntime {
183
401
  source = decision.reason.startsWith('permissive') ? 'permissive' : 'default';
184
402
  break;
185
403
  }
186
- this.audit.append(makeEntry({ callId, tool: exec.name, outcome, source, reason: decision.reason, at: Date.now() }));
187
- this.recordEvent(exec, outcome === 'allow' ? 'auto' : outcome === 'deny' ? 'deny' : 'ask', decision.reason);
404
+ // An ask the gate cannot deliver: under `approval: never` the DSH approval
405
+ // seam rejects before any answerer runs, so forwarding it would only ever
406
+ // produce `the user rejected tool "..."`. Degrade to passthrough instead.
407
+ if (decision.action === 'ask' && !this.askAnswerable(exec)) {
408
+ const reason = `passthrough: approval policy "never" cannot answer an ask — ${decision.reason}`;
409
+ this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source, reason, at: Date.now() }));
410
+ this.recordEvent(exec, 'auto', reason, { verdict: 'preset-passthrough' });
411
+ return undefined;
412
+ }
413
+ // Only log terminal outcomes; 'ask' is intermediate and must not pollute
414
+ // the audit or the event feed — recording it would be recursive since the
415
+ // user's terminal answer comes back through tools/result anyway.
416
+ if (outcome !== 'ask') {
417
+ this.audit.append(makeEntry({ callId, tool: exec.name, outcome, source, reason: decision.reason, at: Date.now() }));
418
+ this.recordEvent(exec, outcome === 'allow' ? 'auto' : outcome === 'deny' ? 'deny' : 'ask', decision.reason, { verdict: source });
419
+ }
188
420
  if (decision.action === 'deny')
189
421
  return { kind: 'deny', reason: decision.reason };
190
- if (decision.action === 'ask')
422
+ if (decision.action === 'ask') {
423
+ // Track before returning: the approval observer can settle this ask
424
+ // before the call ever reaches `tools/result`.
425
+ this.trackAsk(exec, decision.reason);
191
426
  return { kind: 'ask', reason: decision.reason };
427
+ }
192
428
  return undefined;
193
429
  }
194
430
  /**
@@ -220,42 +456,77 @@ export class PermGateRuntime {
220
456
  */
221
457
  async refineAsk(exec, decision) {
222
458
  const permissive = this.livePermissive();
223
- if (!permissive.enabled || !permissive.strategies.llmAssist)
459
+ if (!permissive.enabled)
224
460
  return decision;
225
461
  if (decision.kind !== 'ask')
226
462
  return decision;
227
- // One live read of the classifier setup. Without a fully configured custom
228
- // endpoint the ask goes straight to the human seam (fail-closed) — unless a
229
- // risk hook is injected (tests / direct embedding replace the HTTP grader).
463
+ // Sedimented learning rules (checked before any LLM work): a confirmed
464
+ // sample of a threshold-reached key auto-allows deterministically. This is
465
+ // the "沉淀" of verdict learning — it keeps working even with llmAssist
466
+ // off, because the human confirmations already happened. Deny-first and
467
+ // P0 are untouched: this only ever converts an `ask` into an allow.
468
+ const learning = this.liveRiskLearning();
469
+ if (learning.enabled && this.liveRiskSediment()) {
470
+ const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
471
+ const key = learnKey(exec.name, 'neutral', fp);
472
+ if (this.learning.shouldAutoAllow(key, fp)) {
473
+ const reason = `learned sediment allow (${key}, ${fp})`;
474
+ this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
475
+ this.recordEvent(exec, 'learned', reason, { risk: 'neutral', verdict: 'learned-sediment', category: 'neutral' });
476
+ this.untrackAsk(exec); // auto-allowed: no human answer is coming
477
+ return undefined;
478
+ }
479
+ }
480
+ if (!permissive.strategies.llmAssist)
481
+ return decision;
482
+ // One live read of the classifier setup. Without a usable receiver (a
483
+ // configured custom endpoint, or the host llm service in host mode) the
484
+ // ask goes straight to the human seam (fail-closed) — unless a risk hook
485
+ // is injected (tests / direct embedding replace the grader entirely).
230
486
  const cfg = this.options.readClassifyConfig?.();
231
- if (this.options.riskHook === undefined
487
+ const useHost = this.liveClassifySource() === 'host' && this.options.hostLlm !== undefined;
488
+ if (this.options.riskHook === undefined && !useHost
232
489
  && (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
233
490
  return decision;
234
491
  }
235
492
  const req = { tool: exec.name, args: exec.arguments ?? {}, reason: decision.reason };
236
- const risk = this.options.riskHook !== undefined
237
- ? await this.options.riskHook(req)
238
- : await classifyRisk(cfg ?? {}, req);
493
+ let risk;
494
+ if (this.options.riskHook !== undefined) {
495
+ risk = await this.options.riskHook(req);
496
+ }
497
+ else if (useHost) {
498
+ const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
499
+ risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
500
+ }
501
+ else {
502
+ risk = await classifyRisk(cfg ?? {}, req);
503
+ }
239
504
  if (risk.kind === 'safe') {
240
505
  const reason = `llm-assist risk: safe${risk.reason === undefined ? '' : ` (${risk.reason})`}`;
241
506
  this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
242
- this.recordEvent(exec, 'auto', reason, 'safe');
507
+ this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'llm-safe' });
508
+ this.untrackAsk(exec); // auto-allowed: no human answer is coming
243
509
  return undefined;
244
510
  }
245
511
  if (risk.kind === 'risky') {
246
512
  if (risk.category !== 'neutral') {
247
- // Hard category (or an off-protocol one, graded hard): always the human
248
- // seam — never auto-allowed, never learned.
249
- const reason = `${decision.reason} [llm-assist risky:${risk.category} → human]`;
250
- return { kind: 'ask', reason };
513
+ // Hard category (deletion / credential / remote / system / bulk) or an
514
+ // off-protocol output graded hard: auto-deny. The operation is clearly
515
+ // dangerous or sensitive — no popup, no learning, just block.
516
+ const reason = `${decision.reason} [llm-assist risky:${risk.category} → auto-deny]`;
517
+ this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'deny', source: 'classifier', reason, at: Date.now() }));
518
+ this.recordEvent(exec, 'deny', reason, { risk: risk.category, verdict: 'llm-deny', category: risk.category });
519
+ this.untrackAsk(exec);
520
+ return { kind: 'deny', reason };
251
521
  }
252
522
  const learning = this.liveRiskLearning();
253
- const key = learnKey(exec.name, risk.category);
254
523
  const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
524
+ const key = learnKey(exec.name, risk.category, fp);
255
525
  if (learning.enabled && this.learning.shouldAutoAllow(key, fp)) {
256
526
  const reason = `llm-assist learned allow (${key}, ${fp})`;
257
527
  this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
258
- this.recordEvent(exec, 'auto', reason, risk.category);
528
+ this.recordEvent(exec, 'auto', reason, { risk: risk.category, verdict: 'llm-learned', category: risk.category });
529
+ this.untrackAsk(exec); // auto-allowed: no human answer is coming
259
530
  return undefined;
260
531
  }
261
532
  if (learning.enabled) {
@@ -266,17 +537,45 @@ export class PermGateRuntime {
266
537
  }
267
538
  this.pending.set(canonicalizeCall(exec.name, exec.arguments ?? {}), { key, fp, ctx: decision.reason });
268
539
  }
269
- return { kind: 'ask', reason: `${decision.reason} [llm-assist risky:neutral]` };
540
+ const neutralReason = `${decision.reason} [llm-assist risky:neutral]`;
541
+ // The refined reason supersedes the raw ask, and the candidate key lets the
542
+ // terminal event report the post-approval learning progress.
543
+ this.trackAsk(exec, neutralReason, learning.enabled ? key : undefined);
544
+ return { kind: 'ask', reason: neutralReason };
270
545
  }
271
546
  // unresolved: keep the ask, learn nothing.
272
547
  return decision;
273
548
  }
274
549
  /**
275
- * Settle a learning candidate after the call finished (`tools/result`).
276
- * A result arriving for a registered pending candidate means the human
277
- * approved it and it actually executed — record one confirmation.
550
+ * Record the human's terminal answer for one tracked ask. This is the primary
551
+ * channel, driven by the `approval/request` observer, which sees the closed
552
+ * outcome (`allowed-once` / `rejected` / `cancelled` / `unavailable`) without
553
+ * having to parse any reason text.
554
+ *
555
+ * Consuming the entry is the entire de-duplication rule: a second call for the
556
+ * same ask — or a later `tools/result` — finds nothing and records nothing.
557
+ * @returns whether an ask was settled.
278
558
  */
279
- settleExecution(exec) {
559
+ settleAskOutcome(callId, outcome) {
560
+ if (callId === '')
561
+ return false;
562
+ const key = `id:${callId}`;
563
+ const ask = this.pendingAsks.get(key);
564
+ if (ask === undefined)
565
+ return false;
566
+ this.pendingAsks.delete(key);
567
+ this.recordAskOutcome(ask, outcome);
568
+ return true;
569
+ }
570
+ /**
571
+ * Settle after the call finished (`tools/result`): the fallback channel for an
572
+ * ask whose approval never surfaced to the observer (no approval service, a
573
+ * missing `callId`, or an earlier listener short-circuiting the waterfall).
574
+ * Also settles a pending learning candidate: a result for one means the human
575
+ * approved it and it actually executed — one confirmation.
576
+ */
577
+ settleExecution(exec, result) {
578
+ this.settleAskFromResult(exec, result);
280
579
  const callKey = canonicalizeCall(exec.name, exec.arguments ?? {});
281
580
  const candidate = this.pending.get(callKey);
282
581
  if (candidate === undefined)
@@ -285,7 +584,151 @@ export class PermGateRuntime {
285
584
  if (!this.liveRiskLearning().enabled)
286
585
  return;
287
586
  this.learning.confirm(candidate.key, candidate.fp, candidate.ctx);
288
- this.recordEvent(exec, 'learned', `confirmed ${candidate.key} (${candidate.fp})`, 'neutral');
587
+ this.recordEvent(exec, 'learned', `confirmed ${candidate.key} (${candidate.fp})`, { risk: 'neutral', verdict: 'learned-confirm', category: 'neutral' });
588
+ }
589
+ /**
590
+ * Promote a pending learning candidate to a session-level grant: the same
591
+ * operation auto-passes for the rest of the session (bounded by TTL and
592
+ * max-uses) without waiting for human confirmations. This is the "one-click
593
+ * allow in session" shortcut the settings card can expose.
594
+ *
595
+ * @returns `true` when a pending candidate was promoted.
596
+ */
597
+ approveSessionGrant(exec) {
598
+ const callKey = canonicalizeCall(exec.name, exec.arguments ?? {});
599
+ const candidate = this.pending.get(callKey);
600
+ if (candidate === undefined)
601
+ return false;
602
+ this.pending.delete(callKey);
603
+ // Record the learning confirmation so sediment also benefits.
604
+ if (this.liveRiskLearning().enabled) {
605
+ this.learning.confirm(candidate.key, candidate.fp, candidate.ctx);
606
+ }
607
+ this.grants.mint({
608
+ tool: exec.name,
609
+ fingerprint: callKey,
610
+ decidedBy: 'human',
611
+ parentAuthorized: exec.parentAuthorized !== false,
612
+ ttlMs: this.options.grantTtlMs ?? 5 * 60_000,
613
+ maxUses: this.options.grantMaxUses ?? 1,
614
+ });
615
+ this.untrackAsk(exec);
616
+ this.recordEvent(exec, 'learned', `approved session grant (${candidate.key}, ${candidate.fp})`, { risk: 'neutral', verdict: 'learned-granted', category: 'neutral' });
617
+ return true;
618
+ }
619
+ /** Classify one `tools/result` payload into the ask's terminal outcome. */
620
+ settleAskFromResult(exec, result) {
621
+ const key = this.askKey(exec);
622
+ const ask = this.pendingAsks.get(key);
623
+ if (ask === undefined)
624
+ return;
625
+ const message = typeof result?.error?.message === 'string' ? result.error.message : '';
626
+ const failed = result?.isError === true;
627
+ let outcome;
628
+ if (!failed)
629
+ outcome = 'allowed-once';
630
+ else if (/rejected tool/i.test(message))
631
+ outcome = 'rejected';
632
+ else if (/was cancelled/i.test(message))
633
+ outcome = 'cancelled';
634
+ else if (/approval/i.test(message))
635
+ outcome = 'unavailable';
636
+ // The human allowed it and the tool failed on its own afterwards.
637
+ else
638
+ outcome = 'allowed-once';
639
+ this.pendingAsks.delete(key);
640
+ this.recordAskOutcome(ask, outcome);
641
+ }
642
+ /**
643
+ * Write one terminal event from the ask's snapshotted context. The observer
644
+ * has no execution object of its own, so everything here comes from what
645
+ * {@link trackAsk} captured when the ask was recorded.
646
+ */
647
+ recordAskOutcome(ask, outcome) {
648
+ const exec = {
649
+ name: ask.tool,
650
+ arguments: {},
651
+ sessionId: ask.sessionId,
652
+ cwd: ask.baseDir,
653
+ };
654
+ if (outcome === 'allowed-once') {
655
+ const learning = this.liveRiskLearning();
656
+ // Report the post-approval progress: a registered candidate is confirmed
657
+ // by `tools/result` right after this, so it counts as this approval.
658
+ const key = ask.learningKey ?? learnKey(ask.tool, 'neutral');
659
+ const learningCount = learning.enabled
660
+ ? this.learning.count(key) + (ask.learningKey === undefined ? 0 : 1)
661
+ : undefined;
662
+ this.recordEvent(exec, 'manual-approved', ask.reason, {
663
+ verdict: 'human-approved',
664
+ files: ask.files,
665
+ learningCount,
666
+ threshold: learning.enabled ? learning.threshold : undefined,
667
+ });
668
+ return;
669
+ }
670
+ if (outcome === 'rejected') {
671
+ this.recordEvent(exec, 'manual-rejected', ask.reason, { verdict: 'human-rejected', files: ask.files });
672
+ return;
673
+ }
674
+ if (outcome === 'cancelled') {
675
+ this.recordEvent(exec, 'manual-cancelled', ask.reason, { verdict: 'human-cancelled', files: ask.files });
676
+ return;
677
+ }
678
+ // `unavailable`: no human was involved — the ask degraded to a denial.
679
+ this.recordEvent(exec, 'deny', ask.reason, { verdict: 'no-approval-channel', files: ask.files });
680
+ }
681
+ // ---- Learning-store management (the settings UI's sediment view) ----
682
+ /** The live confirmation threshold the sediment view compares counts against. */
683
+ learningThreshold() {
684
+ return this.liveRiskLearning().threshold;
685
+ }
686
+ /** Terminate one key's learning, or drop a single sedimented sample. */
687
+ learningReset(key, fp) {
688
+ if (fp !== undefined)
689
+ this.learning.dropSample(key, fp);
690
+ else
691
+ this.learning.resetKey(key);
692
+ }
693
+ /**
694
+ * One minimal completion through the currently configured receiver (host
695
+ * model group or custom endpoint) with latency — the settings card's
696
+ * "health test". Never throws; failures come back as `ok: false` + detail.
697
+ */
698
+ async healthCheck() {
699
+ const now = this.options.now ?? Date.now;
700
+ const start = now();
701
+ const done = (ok, detail) => ({ ok, ms: now() - start, detail });
702
+ try {
703
+ const cfg = this.options.readClassifyConfig?.();
704
+ if (this.liveClassifySource() === 'host') {
705
+ if (this.options.hostLlm === undefined)
706
+ return done(false, 'host llm service unavailable');
707
+ const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
708
+ const r = await completeViaHost(this.options.hostLlm, selection, 'Reply with exactly: OK', 'ping', cfg?.timeoutMs ?? 20_000);
709
+ return r.ok ? done(true, `${selection.provider}/${selection.model} → ${r.content.trim().slice(0, 60) || 'ok'}`) : done(false, r.error ?? 'host llm call failed');
710
+ }
711
+ if (cfg?.endpoint === undefined || cfg.endpoint === '' || cfg?.model === undefined || cfg.model === '') {
712
+ return done(false, 'custom endpoint/model not configured');
713
+ }
714
+ const r = await chatCompletion(cfg, 'Reply with exactly: OK', 'ping');
715
+ if (r.ok)
716
+ return done(true, `${cfg.model} → ${r.content.trim().slice(0, 60) || 'ok'}`);
717
+ // Name the failing leg of the endpoint/model/key triple when possible.
718
+ const why = r.error === 'timeout'
719
+ ? `timeout after ${cfg.timeoutMs ?? 20_000} ms`
720
+ : r.status === 401 || r.status === 403
721
+ ? `HTTP ${r.status} — API key rejected`
722
+ : r.status === 404
723
+ ? 'HTTP 404 — endpoint path or model id not found'
724
+ : r.status !== undefined
725
+ ? `HTTP ${r.status} — request rejected (check model id / endpoint shape)`
726
+ : 'network error (endpoint unreachable)';
727
+ return done(false, why);
728
+ }
729
+ catch (e) {
730
+ return done(false, String(e?.message ?? e));
731
+ }
289
732
  }
290
733
  /** Mint a precise session grant bound to a canonical call fingerprint. */
291
734
  grant(exec, maxUses, ttlMs) {
@@ -345,6 +788,119 @@ function readFileSafe(p) {
345
788
  return '';
346
789
  }
347
790
  }
791
+ /** Path-ish tokens and bare filenames lifted out of a command string. */
792
+ const COMMAND_PATH_RE = /(?:~\/|\/|\.\/)?[\w@.-]+[\\/][\w@.\\/-]+/g;
793
+ const COMMAND_FILE_RE = /[\w@.-]+\.(?:md|js|json|ya?ml|env|txt|py|ts|tsx|css|html|log|mjs|cjs|sh|ps1|toml|ini|cfg|conf)/gi;
794
+ /** A bare filename (no separator) still names a real target. */
795
+ const BARE_FILE_RE = /^[\w@.-]+\.(?:md|js|json|ya?ml|env|txt|py|ts|tsx|css|html|log|mjs|cjs|sh|ps1|toml|ini|cfg|conf)$/i;
796
+ /**
797
+ * Paths one decision concerns, for the review page's file chips and snapshots.
798
+ * Explicit argument candidates win (edit/write/read style tools); otherwise the
799
+ * paths are lifted from a write-shaped shell command only — a read command
800
+ * (cat/tail/grep) changes nothing, so its paths would be false positives.
801
+ */
802
+ function eventFiles(exec) {
803
+ const out = [];
804
+ const seen = new Set();
805
+ const add = (value) => {
806
+ if (typeof value !== 'string')
807
+ return;
808
+ const seg = value.trim();
809
+ if (seg.length < 3 || seg.length > 1024)
810
+ return;
811
+ if (/^(https?:|data:|blob:)/i.test(seg))
812
+ return;
813
+ if (!seg.includes('/') && !seg.includes('\\') && !BARE_FILE_RE.test(seg))
814
+ return;
815
+ if (seen.has(seg))
816
+ return;
817
+ seen.add(seg);
818
+ out.push(seg);
819
+ };
820
+ for (const candidate of extractPathCandidates(exec.arguments ?? {}))
821
+ add(candidate);
822
+ if (out.length === 0) {
823
+ const raw = typeof exec.arguments?.command === 'string'
824
+ ? exec.arguments.command
825
+ : (exec.commandText ?? '');
826
+ if (raw !== '') {
827
+ // Strip stderr suppression (2>/dev/null, 2>&1) — a read command's idiom,
828
+ // never a write target.
829
+ const clean = raw.replace(/2>>?\/dev\/null/g, ' ').replace(/2>&1/g, ' ');
830
+ const hasWrite = /(^|[;&|]\s*)(touch|cp|mv|rm|tee|mkdir|rmdir|install|dd|truncate|shred|chmod|chown|chgrp)\b/i.test(clean)
831
+ || /(^|[;&|]\s*)(sed|perl|python|node|ruby)\b[^;|]*\s-i\b/i.test(clean)
832
+ || /(^|[;&|]\s*)(curl|wget)\b[^;|]*\s(-o|--output|-O)\b/i.test(clean)
833
+ || /(^|[;&|]\s*)(npm|pnpm|yarn|pip|pip3|gem|go|brew)\b[^;|]*\s(install|add|update|remove|uninstall)\b/i.test(clean)
834
+ || />>?|&>/.test(clean.replace(/[^<>=]/g, '').replace(/<<+/g, ''));
835
+ if (hasWrite) {
836
+ for (const m of clean.matchAll(COMMAND_PATH_RE))
837
+ add(m[0]);
838
+ for (const m of clean.matchAll(COMMAND_FILE_RE))
839
+ add(m[0]);
840
+ }
841
+ }
842
+ }
843
+ return out.slice(0, 8);
844
+ }
845
+ /** Keyword → matcher cache (the blacklist is editable at runtime). */
846
+ const keywordMatchers = new Map();
847
+ /**
848
+ * Build the matcher for one blacklist keyword. An ASCII-edged keyword is matched
849
+ * against identifier characters — letters, digits, `_` and `-` — so it can no
850
+ * longer veto a longer identifier that merely contains it: `format` no longer
851
+ * matches the PowerShell cmdlet `Format-Table`, while `mkfs` still matches
852
+ * `mkfs.ext4` (`.` ends the identifier). A keyword ending in punctuation keeps
853
+ * its literal edge (a device target keyword still matches its argument), and CJK
854
+ * keywords (no `\w` edges) stay plain substrings.
855
+ */
856
+ function keywordMatcher(keyword) {
857
+ const cached = keywordMatchers.get(keyword);
858
+ if (cached !== undefined)
859
+ return cached;
860
+ const needle = keyword.trim().replace(/\s+/g, ' ');
861
+ const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
862
+ const lead = /^\w/.test(needle) ? '(?<![A-Za-z0-9_-])' : '';
863
+ const trail = /\w$/.test(needle) ? '(?![A-Za-z0-9_-])' : '';
864
+ const re = new RegExp(`${lead}${escaped}${trail}`, 'i');
865
+ keywordMatchers.set(keyword, re);
866
+ return re;
867
+ }
868
+ /**
869
+ * The text a blacklist keyword may match: the shell command plus the call's
870
+ * scalar arguments, excluding document-body fields. Whitespace is collapsed so
871
+ * a command written with extra spaces still reads as the command it is.
872
+ */
873
+ function denyScanText(exec) {
874
+ const parts = [];
875
+ const walk = (node, depth) => {
876
+ if (depth > 6)
877
+ return;
878
+ if (Array.isArray(node)) {
879
+ for (const item of node) {
880
+ if (typeof item === 'string')
881
+ parts.push(item);
882
+ else
883
+ walk(item, depth + 1);
884
+ }
885
+ return;
886
+ }
887
+ if (typeof node !== 'object' || node === null)
888
+ return;
889
+ for (const [key, value] of Object.entries(node)) {
890
+ if (typeof value === 'string') {
891
+ if (!CONTENT_ARG_KEYS.has(key))
892
+ parts.push(value);
893
+ }
894
+ else {
895
+ walk(value, depth + 1);
896
+ }
897
+ }
898
+ };
899
+ walk(exec.arguments ?? {}, 0);
900
+ if (typeof exec.commandText === 'string' && exec.commandText !== '')
901
+ parts.unshift(exec.commandText);
902
+ return parts.join(' ').replace(/\s+/g, ' ');
903
+ }
348
904
  function randomId() {
349
905
  return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
350
906
  }