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.
- package/CHANGELOG.ja.md +110 -1
- package/CHANGELOG.ko.md +108 -1
- package/CHANGELOG.md +159 -1
- package/INSTALL.ja.md +3 -3
- package/INSTALL.ko.md +3 -3
- package/INSTALL.md +4 -3
- package/INSTALL.zh.md +3 -3
- package/README.ja.md +34 -11
- package/README.ko.md +42 -13
- package/README.md +82 -17
- package/README.zh.md +48 -13
- 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 +31 -2
- package/lib/index.js +289 -31
- 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
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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,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
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
|
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 =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
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|
|
|
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[];
|