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/CHANGELOG.ja.md +97 -0
- package/CHANGELOG.ko.md +96 -0
- package/CHANGELOG.md +146 -0
- package/INSTALL.ja.md +2 -2
- package/INSTALL.ko.md +2 -2
- package/INSTALL.md +3 -2
- package/INSTALL.zh.md +2 -2
- package/README.ja.md +29 -6
- package/README.ko.md +37 -8
- package/README.md +73 -11
- package/README.zh.md +44 -9
- package/cordis.patch.yml +19 -12
- package/lib/audit.d.ts +1 -1
- package/lib/classifier.d.ts +2 -0
- package/lib/classifier.js +7 -4
- package/lib/client.js +1786 -53
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +63 -4
- package/lib/config.js +50 -2
- package/lib/deny-defaults.d.ts +12 -0
- package/lib/deny-defaults.js +19 -0
- package/lib/engine.d.ts +13 -1
- package/lib/engine.js +85 -8
- package/lib/evaluate.d.ts +10 -0
- package/lib/evaluate.js +101 -11
- package/lib/events.d.ts +140 -4
- package/lib/events.js +578 -13
- package/lib/host-llm.d.ts +61 -0
- package/lib/host-llm.js +52 -0
- package/lib/index.d.ts +15 -1
- package/lib/index.js +266 -23
- package/lib/learning.d.ts +19 -2
- package/lib/learning.js +47 -5
- package/lib/llm-presets.d.ts +16 -0
- package/lib/llm-presets.js +11 -0
- package/lib/preset.d.ts +30 -0
- package/lib/preset.js +41 -0
- package/lib/receiver-info.d.ts +76 -0
- package/lib/receiver-info.js +70 -0
- package/lib/risk.d.ts +16 -1
- package/lib/risk.js +15 -3
- package/lib/runtime.d.ts +210 -4
- package/lib/runtime.js +589 -33
- package/package.json +6 -5
|
@@ -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
|
+
}>;
|
package/lib/host-llm.js
ADDED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
|
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:
|
|
121
|
-
//
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
131
|
-
// cordis core's typed `Events`, so they are registered through the
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
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|
|
|
30
|
-
export declare function learnKey(tool: 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|
|
|
66
|
-
export function learnKey(tool,
|
|
67
|
-
return `${tool}|${
|
|
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
|
+
];
|
package/lib/preset.d.ts
ADDED
|
@@ -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;
|