dsh-perm-gate 0.2.0 → 0.2.1-beta.3

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
@@ -7,11 +7,40 @@
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis';
9
9
  import { Config } from './config.js';
10
- import { PermGateRuntime } from './runtime.js';
10
+ import { PermGateRuntime, type ToolExecutionLike } 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>;
31
+ /**
32
+ * Build the `tools/pre-execute` waterfall listener (exported for tests).
33
+ *
34
+ * The llmAssist refinement is awaited **before** the decision leaves the
35
+ * waterfall. An ask that has been returned to the host is already on its way to
36
+ * the approval answerers, and DSH offers no API to retract it — the request's
37
+ * own `signal` can only settle it `cancelled` — so a `safe` verdict learned
38
+ * afterwards could be *recorded* but never acted on: the human still had to
39
+ * click. Grading here is what turns `safe` into a real auto-allow and keeps the
40
+ * prompt for the genuinely uncertain verdicts only (`risky:neutral`,
41
+ * `unresolved`, transport failure). The wait is bounded by the classifier's own
42
+ * `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
43
+ * (fail-closed).
44
+ */
45
+ export declare function makePreExecuteListener(runtime: Pick<PermGateRuntime, 'decideExecution' | 'refineAsk'>): (exec: ToolExecutionLike, next: () => Promise<unknown>) => Promise<unknown>;
17
46
  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,138 @@ 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
+ }
100
+ /**
101
+ * Build the `tools/pre-execute` waterfall listener (exported for tests).
102
+ *
103
+ * The llmAssist refinement is awaited **before** the decision leaves the
104
+ * waterfall. An ask that has been returned to the host is already on its way to
105
+ * the approval answerers, and DSH offers no API to retract it — the request's
106
+ * own `signal` can only settle it `cancelled` — so a `safe` verdict learned
107
+ * afterwards could be *recorded* but never acted on: the human still had to
108
+ * click. Grading here is what turns `safe` into a real auto-allow and keeps the
109
+ * prompt for the genuinely uncertain verdicts only (`risky:neutral`,
110
+ * `unresolved`, transport failure). The wait is bounded by the classifier's own
111
+ * `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
112
+ * (fail-closed).
113
+ */
114
+ export function makePreExecuteListener(runtime) {
115
+ return async (exec, next) => {
116
+ const decision = runtime.decideExecution(exec);
117
+ // Passthrough (allow / stand-down) and a hard-deny need no grading.
118
+ if (decision === undefined)
119
+ return next();
120
+ if (decision.kind !== 'ask')
121
+ return decision;
122
+ // A cancelled call is never worth a model round-trip; the registry rechecks
123
+ // cancellation after this gate settles.
124
+ if (exec.signal?.aborted === true)
125
+ return decision;
126
+ let refined;
127
+ try {
128
+ refined = await runtime.refineAsk(exec, decision);
129
+ }
130
+ catch {
131
+ return decision; // fail-closed: keep the human ask
132
+ }
133
+ return refined ?? next();
134
+ };
135
+ }
38
136
  export function apply(ctx, config = {}) {
39
137
  // Runtime-adjustable config: the composition entry is the base; the settings
40
138
  // namespace layers on top and `current()` always reads the active section.
41
139
  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;
140
+ // Plugin-owned data files live under $DSH_HOME (node_modules may be
141
+ // read-only). The default resolves to `$DSH_HOME` / `~/.dsh`, so a profile
142
+ // entry that omits `config` still records events, snapshots and learning.
143
+ const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
144
+ // Host model-group services (typed minimally; supplied by the dsh runtime
145
+ // through the loader `inject` — dsh-approval-gate demonstrates the same
146
+ // contract). The llm service backs the `host` receiver; agentDefaultModel
147
+ // supplies the current model-group selection.
148
+ const injected = ctx;
149
+ const fallbackGet = (name) => {
150
+ try {
151
+ return injected.get(name);
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ };
157
+ const llmService = (injected.llm ?? fallbackGet('llm'));
158
+ const hostLlm = llmService !== null && typeof llmService === 'object' && typeof llmService.stream === 'function'
159
+ ? llmService
160
+ : undefined;
161
+ const hostModelService = injected.agentDefaultModel ?? fallbackGet('agentDefaultModel');
162
+ // The live approval service, captured by the optional inject below. Its
163
+ // `effectivePolicy` tells the gate whether an ask can reach a human at all.
164
+ //
165
+ // DUAL-VERSION NOTE (DSH 0.1.1-rc.2 and 0.1.2-rc.1): `effectivePolicy` is a
166
+ // **private** method of the user-approval service in BOTH versions
167
+ // (packages/interaction/user-approval/src/index.ts, `private effectivePolicy`).
168
+ // It is therefore a duck-typed, non-contract dependency: read it only through
169
+ // a `typeof` probe, never assume it exists, and never let a failure escape
170
+ // (a throw would be normalized by the approval seam into a rejection on the
171
+ // human's behalf). The public fallback is the `agents.get(id).followup(...)`
172
+ // channel in buildSessionSender.
173
+ let approvalService;
45
174
  const runtime = new PermGateRuntime({
46
175
  ...config,
176
+ hostLlm,
47
177
  // Read the Permissive tier live so the UI card's switches take effect on
48
178
  // the next tool call (no reload). Falls back to the composition entry.
49
179
  readPermissive: () => {
@@ -72,12 +202,71 @@ export function apply(ctx, config = {}) {
72
202
  threshold: c.riskThreshold ?? 3,
73
203
  };
74
204
  },
205
+ // Read the deny-keyword blacklist live; unset (not an array) applies the preset.
206
+ readDenyKeywords: () => {
207
+ const c = current();
208
+ return Array.isArray(c.denyKeywords) ? c.denyKeywords : undefined;
209
+ },
210
+ // Learning sedimentation: threshold-reached samples become deterministic allows.
211
+ readRiskSediment: () => current().riskSediment ?? true,
212
+ // llmAssist receiver source: custom OpenAI-compatible endpoint or the host llm service.
213
+ readClassifySource: () => (current().classifierSource === 'host' ? 'host' : 'custom'),
214
+ readHostModel: () => {
215
+ const c = current();
216
+ let selection;
217
+ try {
218
+ selection = hostModelService?.currentSelection?.();
219
+ }
220
+ catch {
221
+ selection = undefined;
222
+ }
223
+ const provider = typeof c.classifierProvider === 'string' && c.classifierProvider !== ''
224
+ ? c.classifierProvider
225
+ : (typeof selection?.provider === 'string' && selection.provider !== '' ? selection.provider : 'deepseek-official');
226
+ const model = typeof c.classifierModel === 'string' && c.classifierModel !== ''
227
+ ? c.classifierModel
228
+ : (typeof selection?.model === 'string' && selection.model !== '' ? selection.model : 'deepseek-v4-flash');
229
+ return { provider, model };
230
+ },
75
231
  learningFile: typeof config.learningFile === 'string' && config.learningFile !== ''
76
232
  ? config.learningFile
77
- : (dataDir !== undefined ? join(dataDir, 'learning.json') : undefined),
233
+ : join(dataDir, 'learning.json'),
234
+ // The rules document lives in the plugin's data dir by default, so a
235
+ // `$DSH_HOME/perm-gate/rules.yml` the user writes is actually loaded without
236
+ // also having to declare `rulesFile` in the composition entry.
237
+ rulesFile: resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir),
238
+ // The whole gate is scoped to the presets that opt into it (default: the
239
+ // `permissive` tier this plugin adds); elsewhere it stands down entirely.
240
+ gatePresets: resolveGatePresets(Array.isArray(config.gatePresets) ? config.gatePresets : undefined),
241
+ // Third-party read-only tools the user classified as safe; the built-in
242
+ // read-only/internal sets already cover DSH's own tools.
243
+ autoAllowTools: Array.isArray(config.autoAllowTools) ? config.autoAllowTools : undefined,
244
+ // `approval: never` rejects every request before any answerer runs, so an
245
+ // ask the gate cannot answer must pass through instead of denying.
246
+ //
247
+ // Capability probe, not a version check: `effectivePolicy` is private in
248
+ // both DSH 0.1.1-rc.2 and 0.1.2-rc.1, so an absent or throwing reader means
249
+ // "unknown policy" — return undefined and let the ask stand (the approval
250
+ // seam then decides). Never surface the failure to the decision path.
251
+ readApprovalPolicy: (exec) => {
252
+ const read = approvalService?.effectivePolicy;
253
+ if (typeof read !== 'function')
254
+ return undefined;
255
+ try {
256
+ const policy = read.call(approvalService, exec.agent?.session);
257
+ return typeof policy === 'string' ? policy : undefined;
258
+ }
259
+ catch {
260
+ return undefined;
261
+ }
262
+ },
78
263
  eventsFile: typeof config.eventsFile === 'string' && config.eventsFile !== ''
79
264
  ? config.eventsFile
80
- : (dataDir !== undefined ? join(dataDir, 'events.jsonl') : undefined),
265
+ : join(dataDir, 'events.jsonl'),
266
+ // Per-event pre-change snapshots back the review page's diff/revert plane.
267
+ snapshotsDir: typeof config.snapshotsDir === 'string' && config.snapshotsDir !== ''
268
+ ? config.snapshotsDir
269
+ : join(dataDir, 'snapshots'),
81
270
  });
82
271
  installSettingsSection(ctx, PERMISSIVE_NAMESPACE, Config, config, {
83
272
  setSource: (source) => {
@@ -105,36 +294,105 @@ export function apply(ctx, config = {}) {
105
294
  // Event feed HTTP API (best effort): the dsh webServer service exposes the
106
295
  // JSONL decision events to the browser half; without it events stay on disk.
107
296
  try {
108
- const webServer = ctx.get('webServer');
297
+ const webServerCandidate = (injected.webServer ?? fallbackGet('webServer'));
298
+ const webServer = webServerCandidate !== null && typeof webServerCandidate === 'object' && typeof webServerCandidate.register === 'function'
299
+ ? webServerCandidate
300
+ : undefined;
301
+ if (webServer === undefined) {
302
+ // Loud but non-fatal: everything else keeps working without the HTTP routes.
303
+ console.warn('[dsh-perm-gate] webServer service unavailable — events/learning/health/receiver routes not registered');
304
+ }
109
305
  if (webServer !== undefined && runtime.eventLog !== undefined) {
110
- registerEventsRoute(webServer, runtime.eventLog);
306
+ const offEvents = registerEventsRoute(webServer, runtime.eventLog);
307
+ if (offEvents !== undefined)
308
+ ctx.effect(() => () => { offEvents(); }, 'dsh-perm-gate: events route');
309
+ }
310
+ // Review-page plane (diff / revert / snapshot stats / snapshot clear): the
311
+ // approval-history view's file chips and snapshot bar drive these.
312
+ if (webServer !== undefined && runtime.eventLog !== undefined && runtime.snapshotDir !== undefined) {
313
+ const offs = registerReviewRoutes(webServer, {
314
+ log: runtime.eventLog,
315
+ snapshotsDir: runtime.snapshotDir,
316
+ send: buildSessionSender(fallbackGet),
317
+ });
318
+ for (const off of offs) {
319
+ ctx.effect(() => () => { off(); }, 'dsh-perm-gate: review route');
320
+ }
321
+ }
322
+ if (webServer !== undefined) {
323
+ const offLearning = registerLearningRoute(webServer, {
324
+ snapshot: () => runtime.learningSnapshot(),
325
+ threshold: () => runtime.learningThreshold(),
326
+ reset: (key, fp) => { runtime.learningReset(key, fp); },
327
+ });
328
+ if (offLearning !== undefined)
329
+ ctx.effect(() => () => { offLearning(); }, 'dsh-perm-gate: learning route');
330
+ const offHealth = registerHealthRoute(webServer, { check: () => runtime.healthCheck() });
331
+ if (offHealth !== undefined)
332
+ ctx.effect(() => () => { offHealth(); }, 'dsh-perm-gate: health route');
333
+ // Receiver projection for the settings card (provider/model catalog is
334
+ // potentially slow to enumerate — cached briefly).
335
+ let receiverCache = null;
336
+ const offReceiver = registerReceiverRoute(webServer, {
337
+ info: async () => {
338
+ const now = Date.now();
339
+ if (receiverCache !== null && now - receiverCache.at < 60_000)
340
+ return receiverCache.info;
341
+ const c = current();
342
+ const info = await buildReceiverInfo({
343
+ source: c.classifierSource === 'host' ? 'host' : 'custom',
344
+ llm: llmService,
345
+ currentSelection: () => {
346
+ try {
347
+ return hostModelService?.currentSelection?.();
348
+ }
349
+ catch {
350
+ return undefined;
351
+ }
352
+ },
353
+ overrideProvider: typeof c.classifierProvider === 'string' ? c.classifierProvider : '',
354
+ overrideModel: typeof c.classifierModel === 'string' ? c.classifierModel : '',
355
+ customModel: typeof c.classifierModel === 'string' ? c.classifierModel : '',
356
+ });
357
+ receiverCache = { at: now, info };
358
+ return info;
359
+ },
360
+ });
361
+ if (offReceiver !== undefined)
362
+ ctx.effect(() => () => { offReceiver(); }, 'dsh-perm-gate: receiver route');
111
363
  }
112
364
  }
113
365
  catch {
114
- // webServer unavailable: the feed remains disk-only
366
+ // webServer unavailable: the feed remains disk-only, sediment is view-only in files
115
367
  }
116
- const listener = async (exec, next) => {
117
- const decision = runtime.decideExecution(exec);
118
- if (decision === undefined)
119
- return next();
120
- // llmAssist: refine an `ask` with the risk grader before the human seam.
121
- // undefined = auto-allowed (risk-safe / learned) → proceed.
122
- if (decision.kind === 'ask') {
123
- const refined = await runtime.refineAsk(exec, decision);
124
- if (refined === undefined)
125
- return next();
126
- return refined;
127
- }
128
- return decision;
129
- };
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.
368
+ const listener = makePreExecuteListener(runtime);
369
+ // `tools/pre-execute` / `tools/result` / `approval/request` are service events,
370
+ // not part of cordis core's typed `Events`, so they are registered through the
371
+ // string overload.
132
372
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
373
  const host = ctx;
134
374
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
375
  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); }));
376
+ // Fallback terminal-answer channel: settle the ask from the call's result,
377
+ // and settle a pending learning candidate (a result for one means the human
378
+ // approved it and it executed — one confirmation).
379
+ host.on('tools/result', ((exec, result) => { runtime.settleExecution(exec, result); }));
380
+ // Primary terminal-answer channel: passively observe the approval waterfall.
381
+ // The ask we vetoed is forwarded by dsh-tools to `approval.request(...)`, so
382
+ // the closed outcome is observable here. This listener MUST return `next()`'s
383
+ // value unchanged — a throw is normalized to `unavailable`, i.e. a rejection
384
+ // on the human's behalf. Requests we never tracked (other agents, other
385
+ // sources) are ignored.
386
+ const approvalAware = ctx;
387
+ approvalAware.inject(['approval'], (actx) => {
388
+ // Capture the live service: the gate reads `effectivePolicy` per call to
389
+ // learn whether an ask can reach a human (`approval: never` cannot).
390
+ approvalService = actx.approval;
391
+ const off = actx.on('approval/request', makeApprovalObserver(runtime));
392
+ actx.effect(() => () => {
393
+ off();
394
+ approvalService = undefined;
395
+ }, 'dsh-perm-gate: approval observer');
396
+ });
139
397
  return runtime;
140
398
  }
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[];