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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Host-side LLM transport for dsh-perm-gate's llmAssist: instead of a custom
3
+ * OpenAI-compatible endpoint, the gate may call the DSH host's `llm` service
4
+ * (the model-group setup the user already configured, as demonstrated by
5
+ * dsh-approval-gate's `llm.stream` + `agentDefaultModel.currentSelection`).
6
+ *
7
+ * Transport-only and never throws: any failure resolves `{ ok: false }` so the
8
+ * caller keeps the ask (fail-closed). Node-half only — never imported by the
9
+ * client bundle.
10
+ */
11
+ /** One request to the host `llm` service (shape per the dsh runtime contract). */
12
+ export interface HostLlmRequest {
13
+ readonly provider: string;
14
+ readonly model: string;
15
+ readonly messages: readonly {
16
+ readonly role: 'user';
17
+ readonly content: readonly {
18
+ readonly type: 'text';
19
+ readonly text: string;
20
+ }[];
21
+ }[];
22
+ readonly system?: string;
23
+ readonly temperature?: number;
24
+ readonly reasoningEffort?: string;
25
+ readonly maxTokens?: number;
26
+ readonly signal?: AbortSignal;
27
+ }
28
+ /** One stream chunk: text/reasoning deltas, or a terminal finish event. */
29
+ export interface HostLlmChunk {
30
+ readonly type: string;
31
+ readonly text?: string;
32
+ readonly reason?: {
33
+ readonly kind?: string;
34
+ readonly failure?: {
35
+ readonly message?: string;
36
+ };
37
+ };
38
+ }
39
+ /** The minimal face of the DSH `llm` service this plugin uses. */
40
+ export interface HostLlmLike {
41
+ stream(request: HostLlmRequest): AsyncIterable<HostLlmChunk>;
42
+ }
43
+ /** The model-group selection used when no explicit override is configured. */
44
+ export interface HostModelSelection {
45
+ readonly provider: string;
46
+ readonly model: string;
47
+ }
48
+ /** Fallback matching dsh-approval-gate's resolveModel default. */
49
+ export declare const DEFAULT_HOST_MODEL: HostModelSelection;
50
+ /**
51
+ * One completion through the host `llm` service with an abort timeout.
52
+ * Resolves `{ ok: true, content }` with the accumulated assistant text, or
53
+ * `{ ok: false, error }` on stream failure, abort, or an empty answer.
54
+ */
55
+ export declare function completeViaHost(hostLlm: HostLlmLike, selection: HostModelSelection, system: string, user: string, timeoutMs: number): Promise<{
56
+ ok: true;
57
+ content: string;
58
+ } | {
59
+ ok: false;
60
+ error?: string;
61
+ }>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Host-side LLM transport for dsh-perm-gate's llmAssist: instead of a custom
3
+ * OpenAI-compatible endpoint, the gate may call the DSH host's `llm` service
4
+ * (the model-group setup the user already configured, as demonstrated by
5
+ * dsh-approval-gate's `llm.stream` + `agentDefaultModel.currentSelection`).
6
+ *
7
+ * Transport-only and never throws: any failure resolves `{ ok: false }` so the
8
+ * caller keeps the ask (fail-closed). Node-half only — never imported by the
9
+ * client bundle.
10
+ */
11
+ /** Fallback matching dsh-approval-gate's resolveModel default. */
12
+ export const DEFAULT_HOST_MODEL = { provider: 'deepseek-official', model: 'deepseek-v4-flash' };
13
+ /**
14
+ * One completion through the host `llm` service with an abort timeout.
15
+ * Resolves `{ ok: true, content }` with the accumulated assistant text, or
16
+ * `{ ok: false, error }` on stream failure, abort, or an empty answer.
17
+ */
18
+ export async function completeViaHost(hostLlm, selection, system, user, timeoutMs) {
19
+ const controller = new AbortController();
20
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
21
+ try {
22
+ let text = '';
23
+ const stream = hostLlm.stream({
24
+ provider: selection.provider,
25
+ model: selection.model,
26
+ messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
27
+ system,
28
+ temperature: 0,
29
+ reasoningEffort: 'off',
30
+ maxTokens: 256,
31
+ signal: controller.signal,
32
+ });
33
+ for await (const chunk of stream) {
34
+ if ((chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') && typeof chunk.text === 'string') {
35
+ text += chunk.text;
36
+ }
37
+ else if (chunk.type === 'finish' && chunk.reason !== undefined && (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted')) {
38
+ const failure = chunk.reason.failure?.message;
39
+ return { ok: false, error: failure ?? chunk.reason.kind };
40
+ }
41
+ }
42
+ if (text.trim() === '')
43
+ return { ok: false, error: 'empty answer' };
44
+ return { ok: true, content: text };
45
+ }
46
+ catch (e) {
47
+ return { ok: false, error: String(e?.message ?? e) };
48
+ }
49
+ finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
package/lib/index.d.ts CHANGED
@@ -9,9 +9,23 @@ import type { Context } from '@deepseek-ai/cordis';
9
9
  import { Config } from './config.js';
10
10
  import { PermGateRuntime } from './runtime.js';
11
11
  export declare const name = "dsh-perm-gate";
12
- /** The `tools` service drives `tools/pre-execute`/`tools/result`; it is supplied by dsh-tools. */
12
+ /**
13
+ * The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
14
+ * `webServer` hosts the plugin's HTTP routes; `llm` + `agentDefaultModel` back
15
+ * the `host` llmAssist receiver — all supplied by the dsh runtime through the
16
+ * loader inject (the same pattern dsh-approval-gate demonstrates; probing via
17
+ * `ctx.get` does not cross the plugin's isolated context).
18
+ */
13
19
  export declare const inject: string[];
14
20
  export { Config };
15
21
  /** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
16
22
  export declare const PERMISSIVE_NAMESPACE = "dsh-perm-gate";
23
+ /**
24
+ * Build the passive `approval/request` observer (exported for tests).
25
+ *
26
+ * It forwards the waterfall untouched and records the closed outcome for a
27
+ * tracked ask. Recording is best-effort: a throw here would be normalized by
28
+ * the approval service to `unavailable` — a rejection on the human's behalf.
29
+ */
30
+ export declare function makeApprovalObserver(runtime: Pick<PermGateRuntime, 'settleAskOutcome'>): (req: unknown, next: () => Promise<unknown>) => Promise<unknown>;
17
31
  export declare function apply(ctx: Context, config?: Record<string, unknown>): PermGateRuntime;
package/lib/index.js CHANGED
@@ -1,10 +1,17 @@
1
1
  import { join } from 'node:path';
2
- import { Config, resolvePermissiveStrategies } from './config.js';
3
- import { registerEventsRoute } from './events.js';
2
+ import { Config, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
3
+ import { registerEventsRoute, registerHealthRoute, registerLearningRoute, registerReceiverRoute, registerReviewRoutes } from './events.js';
4
+ import { buildReceiverInfo } from './receiver-info.js';
4
5
  import { PermGateRuntime } from './runtime.js';
5
6
  export const name = 'dsh-perm-gate';
6
- /** The `tools` service drives `tools/pre-execute`/`tools/result`; it is supplied by dsh-tools. */
7
- export const inject = ['tools'];
7
+ /**
8
+ * The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
9
+ * `webServer` hosts the plugin's HTTP routes; `llm` + `agentDefaultModel` back
10
+ * the `host` llmAssist receiver — all supplied by the dsh runtime through the
11
+ * loader inject (the same pattern dsh-approval-gate demonstrates; probing via
12
+ * `ctx.get` does not cross the plugin's isolated context).
13
+ */
14
+ export const inject = ['tools', 'webServer', 'llm', 'agentDefaultModel'];
8
15
  export { Config };
9
16
  /** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
10
17
  export const PERMISSIVE_NAMESPACE = 'dsh-perm-gate';
@@ -35,15 +42,102 @@ function asSurface(value) {
35
42
  ? value
36
43
  : undefined;
37
44
  }
45
+ /**
46
+ * Deliver a revert instruction into a conversation (the review page's "撤销此改动").
47
+ * A DSH user message content must be a block array — a bare string is rendered
48
+ * per character by the GUI — and two channels are tried in order: the typert
49
+ * gateway, then the live agent's followup.
50
+ */
51
+ function buildSessionSender(get) {
52
+ return async (sessionId, content) => {
53
+ const textBlock = [{ type: 'text', text: content }];
54
+ const gateway = get('typertGateway');
55
+ if (gateway !== undefined && typeof gateway.invoke === 'function') {
56
+ try {
57
+ await gateway.invoke({ namespace: 'session', method: 'prompt', args: { sessionId, mode: 'queue', content: textBlock } });
58
+ return { ok: true, via: 'gateway' };
59
+ }
60
+ catch {
61
+ // fall through to the agent channel
62
+ }
63
+ }
64
+ const agents = get('agents');
65
+ if (agents !== undefined && typeof agents.get === 'function') {
66
+ const agent = agents.get(sessionId);
67
+ if (agent !== undefined && typeof agent.followup === 'function') {
68
+ agent.followup({
69
+ id: 'pg-revert-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e9).toString(36),
70
+ role: 'user',
71
+ content: textBlock,
72
+ source: { kind: 'user' },
73
+ });
74
+ return { ok: true, via: 'followup' };
75
+ }
76
+ }
77
+ return { ok: false, error: '没有可用的消息投递通道' };
78
+ };
79
+ }
80
+ /**
81
+ * Build the passive `approval/request` observer (exported for tests).
82
+ *
83
+ * It forwards the waterfall untouched and records the closed outcome for a
84
+ * tracked ask. Recording is best-effort: a throw here would be normalized by
85
+ * the approval service to `unavailable` — a rejection on the human's behalf.
86
+ */
87
+ export function makeApprovalObserver(runtime) {
88
+ return async (req, next) => {
89
+ const outcome = await next();
90
+ try {
91
+ const callId = req?.callId;
92
+ runtime.settleAskOutcome(typeof callId === 'string' ? callId : '', String(outcome ?? ''));
93
+ }
94
+ catch {
95
+ // Recording must never influence the approval outcome.
96
+ }
97
+ return outcome;
98
+ };
99
+ }
38
100
  export function apply(ctx, config = {}) {
39
101
  // Runtime-adjustable config: the composition entry is the base; the settings
40
102
  // namespace layers on top and `current()` always reads the active section.
41
103
  let current = () => config;
42
- // Plugin-owned data files live under $DSH_HOME (node_modules may be read-only);
43
- // a missing dshHome degrades both stores to in-memory.
44
- const dataDir = typeof config.dshHome === 'string' && config.dshHome !== '' ? join(config.dshHome, 'perm-gate') : undefined;
104
+ // Plugin-owned data files live under $DSH_HOME (node_modules may be
105
+ // read-only). The default resolves to `$DSH_HOME` / `~/.dsh`, so a profile
106
+ // entry that omits `config` still records events, snapshots and learning.
107
+ const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
108
+ // Host model-group services (typed minimally; supplied by the dsh runtime
109
+ // through the loader `inject` — dsh-approval-gate demonstrates the same
110
+ // contract). The llm service backs the `host` receiver; agentDefaultModel
111
+ // supplies the current model-group selection.
112
+ const injected = ctx;
113
+ const fallbackGet = (name) => {
114
+ try {
115
+ return injected.get(name);
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ };
121
+ const llmService = (injected.llm ?? fallbackGet('llm'));
122
+ const hostLlm = llmService !== null && typeof llmService === 'object' && typeof llmService.stream === 'function'
123
+ ? llmService
124
+ : undefined;
125
+ const hostModelService = injected.agentDefaultModel ?? fallbackGet('agentDefaultModel');
126
+ // The live approval service, captured by the optional inject below. Its
127
+ // `effectivePolicy` tells the gate whether an ask can reach a human at all.
128
+ //
129
+ // DUAL-VERSION NOTE (DSH 0.1.1-rc.2 and 0.1.2-rc.1): `effectivePolicy` is a
130
+ // **private** method of the user-approval service in BOTH versions
131
+ // (packages/interaction/user-approval/src/index.ts, `private effectivePolicy`).
132
+ // It is therefore a duck-typed, non-contract dependency: read it only through
133
+ // a `typeof` probe, never assume it exists, and never let a failure escape
134
+ // (a throw would be normalized by the approval seam into a rejection on the
135
+ // human's behalf). The public fallback is the `agents.get(id).followup(...)`
136
+ // channel in buildSessionSender.
137
+ let approvalService;
45
138
  const runtime = new PermGateRuntime({
46
139
  ...config,
140
+ hostLlm,
47
141
  // Read the Permissive tier live so the UI card's switches take effect on
48
142
  // the next tool call (no reload). Falls back to the composition entry.
49
143
  readPermissive: () => {
@@ -72,12 +166,71 @@ export function apply(ctx, config = {}) {
72
166
  threshold: c.riskThreshold ?? 3,
73
167
  };
74
168
  },
169
+ // Read the deny-keyword blacklist live; unset (not an array) applies the preset.
170
+ readDenyKeywords: () => {
171
+ const c = current();
172
+ return Array.isArray(c.denyKeywords) ? c.denyKeywords : undefined;
173
+ },
174
+ // Learning sedimentation: threshold-reached samples become deterministic allows.
175
+ readRiskSediment: () => current().riskSediment ?? true,
176
+ // llmAssist receiver source: custom OpenAI-compatible endpoint or the host llm service.
177
+ readClassifySource: () => (current().classifierSource === 'host' ? 'host' : 'custom'),
178
+ readHostModel: () => {
179
+ const c = current();
180
+ let selection;
181
+ try {
182
+ selection = hostModelService?.currentSelection?.();
183
+ }
184
+ catch {
185
+ selection = undefined;
186
+ }
187
+ const provider = typeof c.classifierProvider === 'string' && c.classifierProvider !== ''
188
+ ? c.classifierProvider
189
+ : (typeof selection?.provider === 'string' && selection.provider !== '' ? selection.provider : 'deepseek-official');
190
+ const model = typeof c.classifierModel === 'string' && c.classifierModel !== ''
191
+ ? c.classifierModel
192
+ : (typeof selection?.model === 'string' && selection.model !== '' ? selection.model : 'deepseek-v4-flash');
193
+ return { provider, model };
194
+ },
75
195
  learningFile: typeof config.learningFile === 'string' && config.learningFile !== ''
76
196
  ? config.learningFile
77
- : (dataDir !== undefined ? join(dataDir, 'learning.json') : undefined),
197
+ : join(dataDir, 'learning.json'),
198
+ // The rules document lives in the plugin's data dir by default, so a
199
+ // `$DSH_HOME/perm-gate/rules.yml` the user writes is actually loaded without
200
+ // also having to declare `rulesFile` in the composition entry.
201
+ rulesFile: resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir),
202
+ // The whole gate is scoped to the presets that opt into it (default: the
203
+ // `permissive` tier this plugin adds); elsewhere it stands down entirely.
204
+ gatePresets: resolveGatePresets(Array.isArray(config.gatePresets) ? config.gatePresets : undefined),
205
+ // Third-party read-only tools the user classified as safe; the built-in
206
+ // read-only/internal sets already cover DSH's own tools.
207
+ autoAllowTools: Array.isArray(config.autoAllowTools) ? config.autoAllowTools : undefined,
208
+ // `approval: never` rejects every request before any answerer runs, so an
209
+ // ask the gate cannot answer must pass through instead of denying.
210
+ //
211
+ // Capability probe, not a version check: `effectivePolicy` is private in
212
+ // both DSH 0.1.1-rc.2 and 0.1.2-rc.1, so an absent or throwing reader means
213
+ // "unknown policy" — return undefined and let the ask stand (the approval
214
+ // seam then decides). Never surface the failure to the decision path.
215
+ readApprovalPolicy: (exec) => {
216
+ const read = approvalService?.effectivePolicy;
217
+ if (typeof read !== 'function')
218
+ return undefined;
219
+ try {
220
+ const policy = read.call(approvalService, exec.agent?.session);
221
+ return typeof policy === 'string' ? policy : undefined;
222
+ }
223
+ catch {
224
+ return undefined;
225
+ }
226
+ },
78
227
  eventsFile: typeof config.eventsFile === 'string' && config.eventsFile !== ''
79
228
  ? config.eventsFile
80
- : (dataDir !== undefined ? join(dataDir, 'events.jsonl') : undefined),
229
+ : join(dataDir, 'events.jsonl'),
230
+ // Per-event pre-change snapshots back the review page's diff/revert plane.
231
+ snapshotsDir: typeof config.snapshotsDir === 'string' && config.snapshotsDir !== ''
232
+ ? config.snapshotsDir
233
+ : join(dataDir, 'snapshots'),
81
234
  });
82
235
  installSettingsSection(ctx, PERMISSIVE_NAMESPACE, Config, config, {
83
236
  setSource: (source) => {
@@ -105,36 +258,126 @@ export function apply(ctx, config = {}) {
105
258
  // Event feed HTTP API (best effort): the dsh webServer service exposes the
106
259
  // JSONL decision events to the browser half; without it events stay on disk.
107
260
  try {
108
- const webServer = ctx.get('webServer');
261
+ const webServerCandidate = (injected.webServer ?? fallbackGet('webServer'));
262
+ const webServer = webServerCandidate !== null && typeof webServerCandidate === 'object' && typeof webServerCandidate.register === 'function'
263
+ ? webServerCandidate
264
+ : undefined;
265
+ if (webServer === undefined) {
266
+ // Loud but non-fatal: everything else keeps working without the HTTP routes.
267
+ console.warn('[dsh-perm-gate] webServer service unavailable — events/learning/health/receiver routes not registered');
268
+ }
109
269
  if (webServer !== undefined && runtime.eventLog !== undefined) {
110
- registerEventsRoute(webServer, runtime.eventLog);
270
+ const offEvents = registerEventsRoute(webServer, runtime.eventLog);
271
+ if (offEvents !== undefined)
272
+ ctx.effect(() => () => { offEvents(); }, 'dsh-perm-gate: events route');
273
+ }
274
+ // Review-page plane (diff / revert / snapshot stats / snapshot clear): the
275
+ // approval-history view's file chips and snapshot bar drive these.
276
+ if (webServer !== undefined && runtime.eventLog !== undefined && runtime.snapshotDir !== undefined) {
277
+ const offs = registerReviewRoutes(webServer, {
278
+ log: runtime.eventLog,
279
+ snapshotsDir: runtime.snapshotDir,
280
+ send: buildSessionSender(fallbackGet),
281
+ });
282
+ for (const off of offs) {
283
+ ctx.effect(() => () => { off(); }, 'dsh-perm-gate: review route');
284
+ }
285
+ }
286
+ if (webServer !== undefined) {
287
+ const offLearning = registerLearningRoute(webServer, {
288
+ snapshot: () => runtime.learningSnapshot(),
289
+ threshold: () => runtime.learningThreshold(),
290
+ reset: (key, fp) => { runtime.learningReset(key, fp); },
291
+ });
292
+ if (offLearning !== undefined)
293
+ ctx.effect(() => () => { offLearning(); }, 'dsh-perm-gate: learning route');
294
+ const offHealth = registerHealthRoute(webServer, { check: () => runtime.healthCheck() });
295
+ if (offHealth !== undefined)
296
+ ctx.effect(() => () => { offHealth(); }, 'dsh-perm-gate: health route');
297
+ // Receiver projection for the settings card (provider/model catalog is
298
+ // potentially slow to enumerate — cached briefly).
299
+ let receiverCache = null;
300
+ const offReceiver = registerReceiverRoute(webServer, {
301
+ info: async () => {
302
+ const now = Date.now();
303
+ if (receiverCache !== null && now - receiverCache.at < 60_000)
304
+ return receiverCache.info;
305
+ const c = current();
306
+ const info = await buildReceiverInfo({
307
+ source: c.classifierSource === 'host' ? 'host' : 'custom',
308
+ llm: llmService,
309
+ currentSelection: () => {
310
+ try {
311
+ return hostModelService?.currentSelection?.();
312
+ }
313
+ catch {
314
+ return undefined;
315
+ }
316
+ },
317
+ overrideProvider: typeof c.classifierProvider === 'string' ? c.classifierProvider : '',
318
+ overrideModel: typeof c.classifierModel === 'string' ? c.classifierModel : '',
319
+ customModel: typeof c.classifierModel === 'string' ? c.classifierModel : '',
320
+ });
321
+ receiverCache = { at: now, info };
322
+ return info;
323
+ },
324
+ });
325
+ if (offReceiver !== undefined)
326
+ ctx.effect(() => () => { offReceiver(); }, 'dsh-perm-gate: receiver route');
111
327
  }
112
328
  }
113
329
  catch {
114
- // webServer unavailable: the feed remains disk-only
330
+ // webServer unavailable: the feed remains disk-only, sediment is view-only in files
115
331
  }
116
332
  const listener = async (exec, next) => {
117
333
  const decision = runtime.decideExecution(exec);
118
334
  if (decision === undefined)
119
335
  return next();
120
- // llmAssist: refine an `ask` with the risk grader before the human seam.
121
- // undefined = auto-allowed (risk-safe / learned) → proceed.
336
+ // llmAssist: the panel MUST appear immediately after pre-execute returns.
337
+ // We NEVER await refineAsk here — return the original ask immediately so
338
+ // the approval panel pops up. The LLM classifier runs in the background
339
+ // and may auto-allow later via settleExecution.
122
340
  if (decision.kind === 'ask') {
123
- const refined = await runtime.refineAsk(exec, decision);
124
- if (refined === undefined)
125
- return next();
126
- return refined;
341
+ // Fire-and-forget: background LLM grading
342
+ runtime.refineAsk(exec, decision)
343
+ .then((refined) => {
344
+ if (refined === undefined) {
345
+ // classifier said "safe" → auto-allow
346
+ runtime.settleExecution(exec, { isError: false });
347
+ }
348
+ })
349
+ .catch(() => { });
350
+ return decision;
127
351
  }
128
352
  return decision;
129
353
  };
130
- // `tools/pre-execute` / `tools/result` are dsh-tools events, not part of
131
- // cordis core's typed `Events`, so they are registered through the string overload.
354
+ // `tools/pre-execute` / `tools/result` / `approval/request` are service events,
355
+ // not part of cordis core's typed `Events`, so they are registered through the
356
+ // string overload.
132
357
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
358
  const host = ctx;
134
359
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
360
  host.on('tools/pre-execute', listener);
136
- // Settle verdict-learning candidates: a result for a registered ask means the
137
- // human approved it and it executed — one confirmation recorded.
138
- host.on('tools/result', ((exec) => { runtime.settleExecution(exec); }));
361
+ // Fallback terminal-answer channel: settle the ask from the call's result,
362
+ // and settle a pending learning candidate (a result for one means the human
363
+ // approved it and it executed — one confirmation).
364
+ host.on('tools/result', ((exec, result) => { runtime.settleExecution(exec, result); }));
365
+ // Primary terminal-answer channel: passively observe the approval waterfall.
366
+ // The ask we vetoed is forwarded by dsh-tools to `approval.request(...)`, so
367
+ // the closed outcome is observable here. This listener MUST return `next()`'s
368
+ // value unchanged — a throw is normalized to `unavailable`, i.e. a rejection
369
+ // on the human's behalf. Requests we never tracked (other agents, other
370
+ // sources) are ignored.
371
+ const approvalAware = ctx;
372
+ approvalAware.inject(['approval'], (actx) => {
373
+ // Capture the live service: the gate reads `effectivePolicy` per call to
374
+ // learn whether an ask can reach a human (`approval: never` cannot).
375
+ approvalService = actx.approval;
376
+ const off = actx.on('approval/request', makeApprovalObserver(runtime));
377
+ actx.effect(() => () => {
378
+ off();
379
+ approvalService = undefined;
380
+ }, 'dsh-perm-gate: approval observer');
381
+ });
139
382
  return runtime;
140
383
  }
package/lib/learning.d.ts CHANGED
@@ -12,6 +12,13 @@ export interface LearningDoc {
12
12
  /** key → the most recent confirmed samples (bounded). */
13
13
  samples: Record<string, LearningSample[]>;
14
14
  }
15
+ /** One sedimented rule view: a threshold-reached key's confirmed sample. */
16
+ export interface SedimentEntry {
17
+ readonly key: string;
18
+ readonly fp: string;
19
+ readonly ctx: string;
20
+ readonly at: number;
21
+ }
15
22
  export interface RiskLearningOptions {
16
23
  /** Confirmations required before an exact-sample re-run may auto-allow. A getter reads the live setting. Default 3. */
17
24
  readonly threshold?: number | (() => number);
@@ -26,8 +33,8 @@ export interface RiskLearningOptions {
26
33
  * when they perform the same kind of write against the same-named target.
27
34
  */
28
35
  export declare function operationFingerprint(tool: string, args: Record<string, unknown>, commandText?: string): string;
29
- /** The learning key for one call: `tool|category` (only neutral learns today). */
30
- export declare function learnKey(tool: string, category: string): string;
36
+ /** The learning key for one call: `tool|fingerprint` (fingerprint-level learning). */
37
+ export declare function learnKey(tool: string, _category: string, fp?: string): string;
31
38
  export declare class RiskLearning {
32
39
  /** Persistence path; `undefined` keeps state in memory only. */
33
40
  private readonly filePath;
@@ -53,6 +60,16 @@ export declare class RiskLearning {
53
60
  confirm(key: string, fp: string, ctx: string): void;
54
61
  /** Drop all learning state (counts and samples) and persist the empty doc. */
55
62
  reset(): void;
63
+ /**
64
+ * The sedimented-rule view: every confirmed sample of a key whose count
65
+ * reached the threshold. These are the deterministic auto-allow rules —
66
+ * derived from the store, so no extra persistence is needed.
67
+ */
68
+ sedimented(): SedimentEntry[];
69
+ /** Remove one sedimented sample (its fingerprint no longer auto-allows). */
70
+ dropSample(key: string, fp: string): void;
71
+ /** Terminate learning for one key: drop its confirmation count and samples. */
72
+ resetKey(key: string): void;
56
73
  /** Read-only deep view (for tests and diagnostics). */
57
74
  snapshot(): LearningDoc;
58
75
  }
package/lib/learning.js CHANGED
@@ -15,10 +15,12 @@
15
15
  */
16
16
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { dirname } from 'node:path';
18
+ // Single source of truth for the shell-tool roster: `evaluate.ts` owns it, so a
19
+ // newly supported tool name cannot drift between the decision path and the
20
+ // learning fingerprint (which is what hid the missing `shell` entry).
21
+ import { SHELL_TOOLS } from './evaluate.js';
18
22
  const DEFAULT_THRESHOLD = 3;
19
23
  const DEFAULT_MAX_SAMPLES = 10;
20
- /** Shell tool names whose first argument word is the operation handle. */
21
- const SHELL_TOOLS = new Set(['bash', 'pwsh', 'sh', 'cmd', 'powershell']);
22
24
  /** Structured arg keys carrying the operation target. */
23
25
  const TARGET_KEYS = ['file_path', 'filePath', 'path', 'file', 'filename', 'target', 'url'];
24
26
  function basename(p) {
@@ -62,9 +64,9 @@ export function operationFingerprint(tool, args, commandText) {
62
64
  }
63
65
  return tool;
64
66
  }
65
- /** The learning key for one call: `tool|category` (only neutral learns today). */
66
- export function learnKey(tool, category) {
67
- return `${tool}|${category}`;
67
+ /** The learning key for one call: `tool|fingerprint` (fingerprint-level learning). */
68
+ export function learnKey(tool, _category, fp) {
69
+ return fp !== undefined ? `${tool}|${fp}` : `${tool}|${_category}`;
68
70
  }
69
71
  function emptyDoc() {
70
72
  return { version: 1, confirmed: {}, samples: {} };
@@ -173,6 +175,46 @@ export class RiskLearning {
173
175
  this.loaded = true;
174
176
  this.persist();
175
177
  }
178
+ /**
179
+ * The sedimented-rule view: every confirmed sample of a key whose count
180
+ * reached the threshold. These are the deterministic auto-allow rules —
181
+ * derived from the store, so no extra persistence is needed.
182
+ */
183
+ sedimented() {
184
+ const doc = this.load();
185
+ const out = [];
186
+ for (const [key, count] of Object.entries(doc.confirmed)) {
187
+ if (count < this.threshold)
188
+ continue;
189
+ for (const s of doc.samples[key] ?? [])
190
+ out.push({ key, fp: s.fp, ctx: s.ctx, at: s.at });
191
+ }
192
+ return out;
193
+ }
194
+ /** Remove one sedimented sample (its fingerprint no longer auto-allows). */
195
+ dropSample(key, fp) {
196
+ const doc = this.load();
197
+ const list = doc.samples[key];
198
+ if (list === undefined)
199
+ return;
200
+ const next = list.filter((s) => s.fp !== fp);
201
+ if (next.length === list.length)
202
+ return;
203
+ if (next.length === 0)
204
+ delete doc.samples[key];
205
+ else
206
+ doc.samples[key] = next;
207
+ this.persist();
208
+ }
209
+ /** Terminate learning for one key: drop its confirmation count and samples. */
210
+ resetKey(key) {
211
+ const doc = this.load();
212
+ if (doc.confirmed[key] === undefined && doc.samples[key] === undefined)
213
+ return;
214
+ delete doc.confirmed[key];
215
+ delete doc.samples[key];
216
+ this.persist();
217
+ }
176
218
  /** Read-only deep view (for tests and diagnostics). */
177
219
  snapshot() {
178
220
  const doc = this.load();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Known OpenAI-compatible endpoint presets for the llmAssist receiver
3
+ * (the settings card fills endpoint + model from one pick; every field stays
4
+ * editable afterwards). Pure data shared by the node half and the client
5
+ * bundle, so it must stay import-free.
6
+ */
7
+ /** One preset receiver endpoint. */
8
+ export interface LlmPreset {
9
+ readonly id: string;
10
+ readonly label: string;
11
+ /** OpenAI-compatible base URL (the `/chat/completions` suffix is normalized away). */
12
+ readonly endpoint: string;
13
+ /** Suggested chat model id (editable after filling). */
14
+ readonly model: string;
15
+ }
16
+ export declare const LLM_PRESETS: readonly LlmPreset[];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Known OpenAI-compatible endpoint presets for the llmAssist receiver
3
+ * (the settings card fills endpoint + model from one pick; every field stays
4
+ * editable afterwards). Pure data shared by the node half and the client
5
+ * bundle, so it must stay import-free.
6
+ */
7
+ export const LLM_PRESETS = [
8
+ { id: 'deepseek', label: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
9
+ { id: 'xiaomi-mimo', label: '小米 MiMo', endpoint: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5' },
10
+ { id: 'openai', label: 'OpenAI', endpoint: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
11
+ ];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Session permission-preset awareness for dsh-perm-gate.
3
+ *
4
+ * `dsh-permission-presets` folds three durable knob events per session
5
+ * (`permission/preset`, `sandbox/mode`, `approval/policy`) and pins an initial
6
+ * `permission/preset` at session creation, so the last `permission/preset`
7
+ * event names the tier the user selected. The gate reads that fold to decide
8
+ * whether its own approval tier is the active one.
9
+ *
10
+ * Reading the session log directly (instead of requiring the
11
+ * `permissionPresets` service) keeps the gate usable in a bare embedding and
12
+ * avoids a hard dependency on a service that may not be composed. The fold
13
+ * matches `effectivePermissionPreset` in dsh-permission-presets: last event
14
+ * wins.
15
+ */
16
+ /** The slice of one session event this module folds. */
17
+ export interface SessionEventLike {
18
+ readonly type?: string;
19
+ readonly data?: {
20
+ readonly preset?: unknown;
21
+ };
22
+ }
23
+ /**
24
+ * Fold the last selected permission preset out of one session's event log.
25
+ * @param events - the session's events in log order.
26
+ * @returns the preset name, or `undefined` when the log records none.
27
+ */
28
+ export declare function permissionPresetOf(events: readonly SessionEventLike[] | undefined): string | undefined;
29
+ /** Whether one preset name is covered by a configured scope list. */
30
+ export declare function presetInScope(preset: string | undefined, scope: readonly string[]): boolean;