dsh-decision-layer 0.7.0 → 0.7.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.
Files changed (61) hide show
  1. package/README.en.md +33 -19
  2. package/README.md +33 -21
  3. package/docs/en/DETAIL.md +20 -0
  4. package/docs/zh/DETAIL.md +20 -0
  5. package/lib/client.js +185 -35
  6. package/lib/host/backend.d.ts +15 -0
  7. package/lib/host/backend.js +149 -0
  8. package/lib/host/config-store.d.ts +12 -0
  9. package/lib/host/config-store.js +254 -0
  10. package/lib/host/danger-gate.d.ts +45 -0
  11. package/lib/host/danger-gate.js +196 -0
  12. package/lib/host/excerpt.d.ts +25 -0
  13. package/lib/host/excerpt.js +62 -0
  14. package/lib/host/index.d.ts +6 -0
  15. package/lib/host/index.js +290 -0
  16. package/lib/host/log-analyze.d.ts +121 -0
  17. package/lib/host/log-analyze.js +254 -0
  18. package/lib/host/log-read.d.ts +4 -0
  19. package/lib/host/log-read.js +34 -0
  20. package/lib/host/log-store.d.ts +15 -0
  21. package/lib/host/log-store.js +98 -0
  22. package/lib/host/loop-guard.d.ts +10 -0
  23. package/lib/host/loop-guard.js +52 -0
  24. package/lib/host/manager-api.d.ts +20 -0
  25. package/lib/host/manager-api.js +174 -0
  26. package/lib/host/narrowing.d.ts +48 -0
  27. package/lib/host/narrowing.js +213 -0
  28. package/lib/host/quality-trend.d.ts +14 -0
  29. package/lib/host/quality-trend.js +22 -0
  30. package/lib/host/self-check.d.ts +27 -0
  31. package/lib/host/self-check.js +104 -0
  32. package/lib/host/session-state.d.ts +19 -0
  33. package/lib/host/session-state.js +288 -0
  34. package/lib/host/state-store.d.ts +14 -0
  35. package/lib/host/state-store.js +109 -0
  36. package/lib/host/steer-once.d.ts +13 -0
  37. package/lib/host/steer-once.js +25 -0
  38. package/lib/host/task-completion.d.ts +29 -0
  39. package/lib/host/task-completion.js +148 -0
  40. package/lib/host/types.d.ts +343 -0
  41. package/lib/host/types.js +11 -0
  42. package/locale/en.json +2 -1
  43. package/locale/zh.json +2 -1
  44. package/package.json +19 -10
  45. package/service/.gitkeep +0 -0
  46. package/service/backend.mjs +0 -127
  47. package/service/config-store.mjs +0 -311
  48. package/service/danger-gate.mjs +0 -182
  49. package/service/dsh.mjs +0 -282
  50. package/service/log-analyze.mjs +0 -256
  51. package/service/log-read.mjs +0 -27
  52. package/service/log-store.mjs +0 -82
  53. package/service/loop-guard.mjs +0 -51
  54. package/service/manager-api.mjs +0 -137
  55. package/service/narrowing.mjs +0 -210
  56. package/service/quality-trend.mjs +0 -29
  57. package/service/self-check.mjs +0 -131
  58. package/service/session-state.mjs +0 -257
  59. package/service/state-store.mjs +0 -100
  60. package/service/steer-once.mjs +0 -25
  61. package/service/task-completion.mjs +0 -163
@@ -1,127 +0,0 @@
1
- import { setTimeout as wait } from 'node:timers/promises';
2
- import { DEFAULT_MODEL } from './config-store.mjs';
3
-
4
- const REQUEST_LIMIT = 256 * 1024;
5
- const RESPONSE_LIMIT = 1024 * 1024;
6
- const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
7
- const isContent = value => typeof value === 'string' ? value.trim().length > 0 : isObject(value) || Array.isArray(value);
8
- const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
9
-
10
- // A backend failure carries a stable `reason` code so callers can surface the
11
- // specific cause (unreachable / http-error / invalid-response / config) instead
12
- // of a single generic error.
13
- function backendError(reason, message) {
14
- const error = new Error(message);
15
- error.reason = reason;
16
- return error;
17
- }
18
-
19
- export function validateRequest(input) {
20
- if (!isObject(input) || !isContent(input.state) || !isObject(input.questions)) throw new Error('Invalid state or questions');
21
- const questions = Object.entries(input.questions);
22
- if (questions.length < 1 || questions.length > 64) throw new Error('Invalid question count');
23
- for (const [id, question] of questions) {
24
- if (!id.trim() || id.length > 160 || !isObject(question) || !isContent(question.instructions)) throw new Error('Invalid question');
25
- if (question.type === 'choice') {
26
- if (!isObject(question.criteria) || Object.keys(question.criteria).length < 2 || Object.keys(question.criteria).length > 255 ||
27
- Object.entries(question.criteria).some(([key, value]) => !key.trim() || (value !== null && !isContent(value)))) throw new Error('Invalid choice criteria');
28
- } else if (question.type === 'score') {
29
- if (!Array.isArray(question.criteria) || question.criteria.length < 2 || question.criteria.length > 10 || !question.criteria.every(isContent)) throw new Error('Invalid score criteria');
30
- } else if (question.type !== 'noul' || (question.criteria !== undefined && (!isObject(question.criteria) || Object.keys(question.criteria).some(key => !['true', 'false'].includes(key))))) {
31
- throw new Error('Invalid question type or criteria');
32
- }
33
- }
34
- let encoded;
35
- try {
36
- encoded = JSON.stringify(input, (key, value) => {
37
- if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint' || (typeof value === 'number' && !Number.isFinite(value))) throw new Error('Invalid JSON');
38
- return value;
39
- });
40
- } catch { throw new Error('Invalid JSON payload'); }
41
- if (Buffer.byteLength(encoded) > REQUEST_LIMIT) throw new Error('Request too large');
42
- return { state: input.state, questions: input.questions };
43
- }
44
-
45
- function validateAnswer(value, question) {
46
- if (!isObject(value) || value.type !== question.type) throw backendError('invalid-response', 'Invalid response answer');
47
- if (question.type === 'noul') {
48
- if (!probability(value.noul)) throw backendError('invalid-response', 'Invalid response probability');
49
- return { type: 'noul', noul: value.noul };
50
- }
51
- const keys = question.type === 'choice' ? Object.keys(question.criteria) : question.criteria.map((_, i) => String(i));
52
- if (!isObject(value.probabilities) || !probability(value.confidence) ||
53
- Object.keys(value.probabilities).length !== keys.length || keys.some(key => !probability(value.probabilities[key])) ||
54
- Math.abs(keys.reduce((sum, key) => sum + value.probabilities[key], 0) - 1) > 0.02) throw backendError('invalid-response', 'Invalid response probabilities');
55
- const common = { type: value.type, confidence: value.confidence, probabilities: Object.fromEntries(keys.map(key => [key, value.probabilities[key]])) };
56
- if (question.type === 'choice') {
57
- if (typeof value.choice !== 'string' || !keys.includes(value.choice)) throw backendError('invalid-response', 'Invalid response choice');
58
- return { ...common, choice: value.choice };
59
- }
60
- if (typeof value.score !== 'number' || !Number.isFinite(value.score) || value.score < 0 || value.score > keys.length - 1) throw backendError('invalid-response', 'Invalid response score');
61
- return { ...common, score: value.score };
62
- }
63
-
64
- function validateResponse(value, questions) {
65
- if (!isObject(value) || !isObject(value.answers) || typeof value.model !== 'string') throw backendError('invalid-response', 'Invalid response');
66
- const answers = Object.create(null);
67
- for (const [id, question] of Object.entries(questions)) answers[id] = validateAnswer(value.answers[id], question);
68
- return { model: value.model, answers, usage: isObject(value.usage) ? {
69
- input_tokens: Number.isSafeInteger(value.usage.input_tokens) ? value.usage.input_tokens : 0,
70
- output_tokens: Number.isSafeInteger(value.usage.output_tokens) ? value.usage.output_tokens : 0,
71
- } : undefined };
72
- }
73
-
74
- async function boundedJson(response) {
75
- const reader = response.body?.getReader();
76
- if (!reader) throw backendError('invalid-response', 'Invalid response body');
77
- const chunks = [];
78
- let size = 0;
79
- try {
80
- while (true) {
81
- const { done, value } = await reader.read();
82
- if (done) break;
83
- size += value.byteLength;
84
- if (size > RESPONSE_LIMIT) throw backendError('invalid-response', 'Invalid response size');
85
- chunks.push(value);
86
- }
87
- } finally { await reader.cancel().catch(() => {}); }
88
- try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw backendError('invalid-response', 'Invalid response JSON'); }
89
- }
90
-
91
- export function createBackend({ config, fetcher = globalThis.fetch, timeoutMs = 20_000, retryDelayMs = 200 }) {
92
- if (typeof config !== 'function' || typeof fetcher !== 'function' ||
93
- !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || !Number.isSafeInteger(retryDelayMs) || retryDelayMs < 0 || retryDelayMs > 5000) {
94
- throw new Error('Invalid backend dependencies');
95
- }
96
- return {
97
- async evaluate(input, { signal } = {}) {
98
- const { state, questions } = validateRequest(input);
99
- const settings = await config();
100
- const key = settings.apiKey?.trim();
101
- if (!key || /[\r\n]/.test(key)) throw backendError('config', 'API key is required');
102
- let url;
103
- try { url = new URL(settings.url); } catch { throw backendError('config', 'Invalid backend URL'); }
104
- if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw backendError('config', 'Invalid backend URL');
105
- if (url.protocol === 'http:' && settings.httpApprovedUrl !== settings.url) throw backendError('config', 'HTTP URL requires confirmation');
106
- const model = settings.model || DEFAULT_MODEL;
107
- if (typeof model !== 'string' || !model.trim() || /[\r\n]/.test(model)) throw backendError('config', 'Invalid model');
108
- const endpoint = new URL(url.href);
109
- endpoint.pathname = `${url.pathname.replace(/\/+$/, '')}/v1/systemone`;
110
- if (endpoint.origin !== url.origin) throw backendError('config', 'Invalid backend endpoint');
111
- const timeout = AbortSignal.timeout(timeoutMs);
112
- const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
113
- for (let attempt = 0; attempt < 3; attempt++) {
114
- let reply;
115
- try {
116
- reply = await fetcher(endpoint.href, { method: 'POST', redirect: 'error', signal: combined,
117
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, body: JSON.stringify({ state, questions, model }) });
118
- } catch { throw backendError('unreachable', 'Backend request failed or timed out'); }
119
- if (reply.ok) return validateResponse(await boundedJson(reply), questions);
120
- await reply.body?.cancel().catch(() => {});
121
- if (![429, 529].includes(reply.status) || attempt === 2) throw backendError('http-error', `Backend HTTP ${reply.status}`);
122
- try { await wait(retryDelayMs * (attempt + 1), undefined, { signal: combined }); }
123
- catch { throw backendError('unreachable', 'Backend request failed or timed out'); }
124
- }
125
- },
126
- };
127
- }
@@ -1,311 +0,0 @@
1
- import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
4
- import { randomUUID } from 'node:crypto';
5
-
6
- export const DEFAULT_URL = 'https://api.typesafe.ai';
7
- export const DEFAULT_MODEL = 'jev-latest';
8
-
9
- export function configPath(env = process.env) {
10
- return env.DSH_DECISION_CONFIG_PATH?.trim() || join(homedir(), '.config', 'dsh-decision-layer', 'config.json');
11
- }
12
-
13
- function safeObject(value) {
14
- return value !== null && typeof value === 'object' && !Array.isArray(value);
15
- }
16
-
17
- async function readStored(path) {
18
- try {
19
- const value = JSON.parse(await readFile(path, 'utf8'));
20
- if (!safeObject(value)) throw new Error('Invalid decision configuration');
21
- return value;
22
- } catch (error) {
23
- if (error?.code === 'ENOENT') return {};
24
- throw error;
25
- }
26
- }
27
-
28
- function safeText(value) {
29
- return typeof value === 'string' ? value.trim() : '';
30
- }
31
-
32
- // A confidence/threshold/probability: any finite number in [0, 1].
33
- function probability(value) {
34
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
35
- }
36
-
37
- function checkedUrl(value) {
38
- if (!value) return '';
39
- let url;
40
- try { url = new URL(value); } catch { throw new Error('Invalid URL'); }
41
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
42
- throw new Error('Invalid URL: expected an HTTP(S) address without credentials, query or fragment');
43
- }
44
- return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
45
- }
46
-
47
- function validRules(value) {
48
- if (!safeObject(value)) return undefined;
49
- const fields = ['addToolNames', 'removeToolNames', 'addCommandPatterns', 'addPathPatterns'];
50
- if (Object.keys(value).some(key => !fields.includes(key))) throw new Error('Invalid danger rules');
51
- const rules = {};
52
- for (const field of fields) {
53
- if (value[field] !== undefined && (!Array.isArray(value[field]) || value[field].length > 64 || value[field].some(item => typeof item !== 'string' || !item.trim() || item.length > 128))) {
54
- throw new Error('Invalid danger rules');
55
- }
56
- if (value[field] !== undefined) rules[field] = value[field].map(item => item.trim());
57
- }
58
- return rules;
59
- }
60
-
61
- // Danger-gate settings. A gate verdict only intervenes on a high-confidence
62
- // `deny`, so minConfidence is the single knob that decides how sure the model
63
- // must be before a dangerous call is actually blocked. Lowering it makes the
64
- // gate block more (and risk blocking legitimate commands); raising it makes it
65
- // pass more through. Range 0–1; unset uses the built-in 0.6.
66
- //
67
- // Like every sibling validator, an unknown field or an out-of-range value is
68
- // REJECTED rather than ignored, which makes the whole resolve reject (see the
69
- // contract note on saveConfig). Callers that read config on a hot path must
70
- // therefore tolerate a throw and fall back to a safe default — see `featureOn`
71
- // in dsh.mjs and `resolveMinConfidence` in danger-gate.mjs.
72
- function validGate(value) {
73
- if (!safeObject(value)) return undefined;
74
- if (Object.keys(value).some(key => key !== 'minConfidence')) throw new Error('Invalid gate settings');
75
- const gate = {};
76
- if (value.minConfidence !== undefined) {
77
- if (!probability(value.minConfidence)) throw new Error('Invalid gate settings');
78
- gate.minConfidence = value.minConfidence;
79
- }
80
- return gate;
81
- }
82
-
83
- function validCheck(value) {
84
- if (!safeObject(value)) return undefined;
85
- if (Object.keys(value).some(key => !['mode', 'rubric', 'lowScoreThreshold', 'minConfidence', 'trendMinConfidence', 'trendRun', 'trendSevereRun'].includes(key))) throw new Error('Invalid self-check settings');
86
- const check = {};
87
- if (value.mode !== undefined) {
88
- if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid self-check settings');
89
- check.mode = value.mode;
90
- }
91
- if (value.rubric !== undefined) {
92
- if (!Array.isArray(value.rubric) || value.rubric.length > 10 || value.rubric.some(item => typeof item !== 'string' || !item.trim() || item.length > 256)) {
93
- throw new Error('Invalid self-check settings');
94
- }
95
- check.rubric = value.rubric.map(item => item.trim());
96
- }
97
- if (value.lowScoreThreshold !== undefined) {
98
- if (!Number.isSafeInteger(value.lowScoreThreshold) || value.lowScoreThreshold < 0 || value.lowScoreThreshold > 9) throw new Error('Invalid self-check settings');
99
- check.lowScoreThreshold = value.lowScoreThreshold;
100
- }
101
- // Confidence required before a low score actually triggers the supplemental
102
- // prompt. Below this the score is still logged; only interrupting the user
103
- // warrants the model being sure. Range 0–1; unset uses the built-in 0.4.
104
- if (value.minConfidence !== undefined) {
105
- if (!probability(value.minConfidence)) throw new Error('Invalid self-check settings');
106
- check.minConfidence = value.minConfidence;
107
- }
108
- // Quality-trend alarm thresholds (all unvalidated heuristics, tuned on real logs):
109
- // a high-confidence low score requires confidence ≥ trendMinConfidence; a run of
110
- // trendRun such scores raises "warn", trendSevereRun raises "severe".
111
- if (value.trendMinConfidence !== undefined) {
112
- if (!probability(value.trendMinConfidence)) throw new Error('Invalid self-check settings');
113
- check.trendMinConfidence = value.trendMinConfidence;
114
- }
115
- if (value.trendRun !== undefined) {
116
- if (!Number.isSafeInteger(value.trendRun) || value.trendRun < 2 || value.trendRun > 10) throw new Error('Invalid self-check settings');
117
- check.trendRun = value.trendRun;
118
- }
119
- if (value.trendSevereRun !== undefined) {
120
- if (!Number.isSafeInteger(value.trendSevereRun) || value.trendSevereRun < 2 || value.trendSevereRun > 20) throw new Error('Invalid self-check settings');
121
- check.trendSevereRun = value.trendSevereRun;
122
- }
123
- // The severe threshold must sit strictly above the warn threshold; check against
124
- // the effective warn value (explicit or default 3) so the pair is always ordered.
125
- if (check.trendSevereRun !== undefined) {
126
- const warn = check.trendRun ?? 3;
127
- if (check.trendSevereRun <= warn) throw new Error('Invalid self-check settings');
128
- }
129
- return check;
130
- }
131
-
132
- function validNarrow(value) {
133
- if (!safeObject(value)) return undefined;
134
- if (Object.keys(value).some(key => !['mode', 'threshold', 'keepPrefixes', 'maxCandidates', 'batchMode'].includes(key))) throw new Error('Invalid narrowing settings');
135
- const narrow = {};
136
- if (value.mode !== undefined) {
137
- if (value.mode !== 'observe' && value.mode !== 'enforce') throw new Error('Invalid narrowing settings');
138
- narrow.mode = value.mode;
139
- }
140
- if (value.threshold !== undefined) {
141
- if (!probability(value.threshold)) throw new Error('Invalid narrowing settings');
142
- narrow.threshold = value.threshold;
143
- }
144
- // Tool name prefixes that are always kept and never judged for relevance, on
145
- // top of the built-in core tools. An explicit empty array clears the list; an
146
- // absent field lets narrowing fall back to its own default (mcp__openviking).
147
- if (value.keepPrefixes !== undefined) {
148
- if (!Array.isArray(value.keepPrefixes) || value.keepPrefixes.length > 32 ||
149
- value.keepPrefixes.some(item => typeof item !== 'string' || !item.trim() || item.length > 128)) {
150
- throw new Error('Invalid narrowing settings');
151
- }
152
- narrow.keepPrefixes = value.keepPrefixes.map(item => item.trim());
153
- }
154
- // Optional-candidate ceiling: above this count, enforce is downgraded to
155
- // observe so a large tool surface is never mass-pruned. 1–200; unset uses the
156
- // narrowing default (20).
157
- if (value.maxCandidates !== undefined) {
158
- if (!Number.isSafeInteger(value.maxCandidates) || value.maxCandidates < 1 || value.maxCandidates > 200) throw new Error('Invalid narrowing settings');
159
- narrow.maxCandidates = value.maxCandidates;
160
- }
161
- // Per-request batching strategy for the relevance pass, chosen by backend
162
- // context capacity. 'single' sends every tool in one systemone request (best
163
- // for large-context backends like Jev — one call). 'split' sends one request
164
- // per tool concurrently (required for small-context backends like tev1, whose
165
- // ~2050-token prompt limit rejects a many-tool request). 'auto' (default)
166
- // tries 'single' first and falls back to 'split' only after a context-overflow
167
- // http-error, so Jev stays at one call and tev1 self-corrects.
168
- if (value.batchMode !== undefined) {
169
- if (!['auto', 'single', 'split'].includes(value.batchMode)) throw new Error('Invalid narrowing settings');
170
- narrow.batchMode = value.batchMode;
171
- }
172
- return narrow;
173
- }
174
-
175
- // Per-feature enable flags for the decision points. Every flag defaults to
176
- // enabled; only an explicit `false` disables its decision point.
177
- export const FEATURE_KEYS = Object.freeze(['gate', 'check', 'narrow', 'complete']);
178
-
179
- // Task-completion checking settings: observe-only by default; steer opts into a
180
- // single supplemental prompt on low completion. minConditions is the minimum
181
- // number of explicit conditions a turn must have for the check to run at all;
182
- // minConfidence is how sure the model must be that a condition is unmet before
183
- // it actually opens the supplemental step.
184
- function validComplete(value) {
185
- if (!safeObject(value)) return undefined;
186
- if (Object.keys(value).some(key => !['mode', 'minConditions', 'minConfidence'].includes(key))) throw new Error('Invalid task-completion settings');
187
- const complete = {};
188
- if (value.mode !== undefined) {
189
- if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid task-completion settings');
190
- complete.mode = value.mode;
191
- }
192
- if (value.minConditions !== undefined) {
193
- if (!Number.isSafeInteger(value.minConditions) || value.minConditions < 2 || value.minConditions > 20) throw new Error('Invalid task-completion settings');
194
- complete.minConditions = value.minConditions;
195
- }
196
- // Range 0–1; unset uses the built-in 0.4.
197
- if (value.minConfidence !== undefined) {
198
- if (!probability(value.minConfidence)) throw new Error('Invalid task-completion settings');
199
- complete.minConfidence = value.minConfidence;
200
- }
201
- return complete;
202
- }
203
-
204
- function validFeatures(value) {
205
- if (!safeObject(value)) return undefined;
206
- if (Object.keys(value).some(key => !FEATURE_KEYS.includes(key))) throw new Error('Invalid feature settings');
207
- const features = {};
208
- for (const key of FEATURE_KEYS) {
209
- if (value[key] !== undefined) {
210
- if (typeof value[key] !== 'boolean') throw new Error('Invalid feature settings');
211
- features[key] = value[key];
212
- }
213
- }
214
- return features;
215
- }
216
-
217
- // Resolve one decision point's enablement, defaulting to enabled when unset.
218
- export function featureEnabled(features, key) {
219
- return !(safeObject(features) && features[key] === false);
220
- }
221
-
222
- function view(stored) {
223
- const result = {
224
- url: safeText(stored.url),
225
- model: safeText(stored.model),
226
- apiKeySet: Boolean(safeText(stored.apiKey)),
227
- httpApprovedUrl: safeText(stored.httpApprovedUrl),
228
- };
229
- if (stored.dangerRules !== undefined) result.dangerRules = validRules(stored.dangerRules);
230
- if (stored.gateSettings !== undefined) result.gateSettings = validGate(stored.gateSettings);
231
- if (stored.checkSettings !== undefined) result.checkSettings = validCheck(stored.checkSettings);
232
- if (stored.narrowSettings !== undefined) result.narrowSettings = validNarrow(stored.narrowSettings);
233
- if (stored.completeSettings !== undefined) result.completeSettings = validComplete(stored.completeSettings);
234
- if (stored.features !== undefined) result.features = validFeatures(stored.features);
235
- return result;
236
- }
237
-
238
- export async function loadConfig(path = configPath()) {
239
- return view(await readStored(path));
240
- }
241
-
242
- export async function resolveConfig({ path = configPath(), env = process.env } = {}) {
243
- const stored = await readStored(path);
244
- const url = checkedUrl(safeText(stored.url) || safeText(env.DSH_DECISION_BASE_URL) || safeText(env.TYPESAFE_BASE_URL) || DEFAULT_URL);
245
- const resolved = {
246
- url,
247
- apiKey: safeText(stored.apiKey) || safeText(env.DSH_DECISION_API_KEY) || safeText(env.TYPESAFE_API_KEY),
248
- model: safeText(stored.model) || safeText(env.DSH_DECISION_MODEL) || DEFAULT_MODEL,
249
- httpApprovedUrl: safeText(stored.httpApprovedUrl),
250
- };
251
- if (stored.dangerRules !== undefined) resolved.dangerRules = validRules(stored.dangerRules);
252
- if (stored.gateSettings !== undefined) resolved.gateSettings = validGate(stored.gateSettings);
253
- if (stored.checkSettings !== undefined) resolved.checkSettings = validCheck(stored.checkSettings);
254
- if (stored.narrowSettings !== undefined) resolved.narrowSettings = validNarrow(stored.narrowSettings);
255
- if (stored.completeSettings !== undefined) resolved.completeSettings = validComplete(stored.completeSettings);
256
- if (stored.features !== undefined) resolved.features = validFeatures(stored.features);
257
- return resolved;
258
- }
259
-
260
- // Write the configuration atomically. Contract for the nested settings sections
261
- // (dangerRules, gateSettings, checkSettings, narrowSettings, completeSettings,
262
- // features): a POSTED section REPLACES the stored one wholesale; an OMITTED
263
- // section keeps its previous value. So a caller updating one field of a section
264
- // must send the whole section, and a caller that wants to change a field inside
265
- // a section it does not own must read first and send `{...previous, edit}`.
266
- // Merge-per-field was considered and rejected: it would make a stored field
267
- // impossible to unset. service/manager-api.mjs forwards the raw body, so this
268
- // contract holds for API clients too, not just the config page.
269
- export async function saveConfig(input, path = configPath()) {
270
- if (!safeObject(input) || Object.keys(input).some(key => !['url', 'apiKey', 'model', 'confirmHttpUrl', 'dangerRules', 'gateSettings', 'checkSettings', 'narrowSettings', 'completeSettings', 'features'].includes(key))) {
271
- throw new Error('Invalid configuration fields');
272
- }
273
- for (const key of ['url', 'apiKey', 'model', 'confirmHttpUrl']) {
274
- if (input[key] !== undefined && typeof input[key] !== 'string') throw new Error(`Invalid ${key}`);
275
- }
276
- const previous = await readStored(path);
277
- const url = checkedUrl(safeText(input.url === undefined ? previous.url : input.url));
278
- const apiKey = safeText(input.apiKey === undefined ? previous.apiKey : input.apiKey);
279
- const model = safeText(input.model === undefined ? previous.model : input.model);
280
- if (model.length > 128 || /[\r\n]/.test(input.model ?? '') || /[\r\n]/.test(input.apiKey ?? '')) {
281
- throw new Error('Invalid model or API key');
282
- }
283
- const dangerRules = input.dangerRules === undefined ? previous.dangerRules : validRules(input.dangerRules);
284
- const gateSettings = input.gateSettings === undefined ? previous.gateSettings : validGate(input.gateSettings);
285
- const checkSettings = input.checkSettings === undefined ? previous.checkSettings : validCheck(input.checkSettings);
286
- const narrowSettings = input.narrowSettings === undefined ? previous.narrowSettings : validNarrow(input.narrowSettings);
287
- const completeSettings = input.completeSettings === undefined ? previous.completeSettings : validComplete(input.completeSettings);
288
- const features = input.features === undefined ? previous.features : validFeatures(input.features);
289
- const confirmed = url.startsWith('http://') && input.confirmHttpUrl !== undefined &&
290
- checkedUrl(safeText(input.confirmHttpUrl)) === url;
291
- if (url.startsWith('http://') && !confirmed && previous.httpApprovedUrl !== url) {
292
- throw new Error('HTTP URL requires explicit confirmation');
293
- }
294
- const next = { url, apiKey, model, httpApprovedUrl: url.startsWith('http://') ? url : '' };
295
- if (dangerRules !== undefined) next.dangerRules = dangerRules;
296
- if (gateSettings !== undefined) next.gateSettings = gateSettings;
297
- if (checkSettings !== undefined) next.checkSettings = checkSettings;
298
- if (narrowSettings !== undefined) next.narrowSettings = narrowSettings;
299
- if (completeSettings !== undefined) next.completeSettings = completeSettings;
300
- if (features !== undefined) next.features = features;
301
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
302
- const temporary = `${path}.${randomUUID()}.tmp`;
303
- try {
304
- await writeFile(temporary, `${JSON.stringify(next)}\n`, { mode: 0o600, flag: 'wx' });
305
- await chmod(temporary, 0o600);
306
- await rename(temporary, path);
307
- } finally {
308
- await rm(temporary, { force: true });
309
- }
310
- return view(next);
311
- }
@@ -1,182 +0,0 @@
1
- const DEFAULT_COMMAND_PATTERNS = Object.freeze([
2
- /\brm\b(?=[^\n]*(?:\s-r(?:f|fr)?\b|\s--recursive\b|\s--force\b))/i,
3
- /\bgit\s+push\b[^\n]*(?:\s-f\b|\s--force(?:-with-lease)?\b)/i,
4
- /\b(?:drop|truncate)\s+(?:table|database)\b/i,
5
- /\bkubectl\s+delete\b/i,
6
- /\b(?:prod|production)\b[^\n]*\b(?:write|deploy|config|secret)\b/i,
7
- ]);
8
- const DEFAULT_PATH_PATTERNS = Object.freeze([/(?:^|[\\/])(?:etc|production|prod)[\\/]/i, /(?:^|[\\/])\.env(?:$|\.)/i]);
9
- const COMMAND_KEYS = new Set(['command', 'cmd', 'script', 'query']);
10
- const PATH_KEYS = new Set(['path', 'file_path', 'filePath', 'target', 'file', 'filename', 'directory']);
11
- const MAX_RULES = 64;
12
- const MAX_PATTERN_LENGTH = 128;
13
- const MAX_METADATA_LENGTH = 2048;
14
- export const MIN_CONFIDENCE = 0.6;
15
-
16
- const isProbability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
17
-
18
- export const DEFAULT_DANGEROUS_RULES = Object.freeze({
19
- toolNames: Object.freeze(['bash', 'pwsh', 'shell', 'exec', 'terminal', 'run_command', 'write', 'edit', 'str_replace_editor', 'file_write', 'write_file']),
20
- commandPatterns: DEFAULT_COMMAND_PATTERNS,
21
- pathPatterns: DEFAULT_PATH_PATTERNS,
22
- });
23
-
24
- const redact = value => value.slice(0, MAX_METADATA_LENGTH)
25
- .replace(/(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+/gi, '$1=[redacted]')
26
- .replace(/(password|passwd|secret|token|api[_-]?key)\s+\S+/gi, '$1 [redacted]');
27
- const literalPattern = value => new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
28
- const boundedList = value => Array.isArray(value) ? value.filter(item => typeof item === 'string' && item.trim() && item.length <= MAX_PATTERN_LENGTH).slice(0, MAX_RULES) : [];
29
-
30
- function collectMetadata(value, depth = 0, commands = [], paths = []) {
31
- if (depth > 4 || value === null || typeof value !== 'object') return;
32
- for (const [key, item] of Object.entries(value)) {
33
- const normalized = key.toLowerCase();
34
- if (typeof item === 'string' && COMMAND_KEYS.has(normalized)) commands.push(redact(item));
35
- else if (typeof item === 'string' && PATH_KEYS.has(normalized)) paths.push(redact(item));
36
- else if (item && typeof item === 'object' && !['content', 'body', 'data', 'metadata', 'headers', 'env', 'value'].includes(normalized)) {
37
- collectMetadata(item, depth + 1, commands, paths);
38
- }
39
- }
40
- return { commands, paths };
41
- }
42
-
43
- export function summarizeExecution(exec) {
44
- const commands = [];
45
- const paths = [];
46
- const collected = collectMetadata(exec?.arguments, 0, commands, paths);
47
- return { tool: typeof exec?.name === 'string' ? exec.name.slice(0, 128) : '',
48
- command: commands.join('\n').slice(0, MAX_METADATA_LENGTH), path: paths.join('\n').slice(0, MAX_METADATA_LENGTH),
49
- inspectable: Boolean(collected) };
50
- }
51
-
52
- export function normalizeDangerRules(input = {}) {
53
- const addTools = boundedList(input.addToolNames);
54
- const removeTools = new Set(boundedList(input.removeToolNames));
55
- const tools = [...new Set([...DEFAULT_DANGEROUS_RULES.toolNames, ...addTools])].filter(tool => !removeTools.has(tool));
56
- const commands = [...DEFAULT_COMMAND_PATTERNS, ...boundedList(input.addCommandPatterns).map(literalPattern)];
57
- const paths = [...DEFAULT_PATH_PATTERNS, ...boundedList(input.addPathPatterns).map(literalPattern)];
58
- return { toolNames: tools, commandPatterns: commands, pathPatterns: paths };
59
- }
60
-
61
- export function classifyDangerous(exec, rules = DEFAULT_DANGEROUS_RULES) {
62
- const normalized = Array.isArray(rules.toolNames) ? rules : normalizeDangerRules(rules);
63
- const summary = summarizeExecution(exec);
64
- if (!normalized.toolNames.includes(summary.tool)) return { dangerous: false, reason: 'Tool is outside the dangerous-call rules' };
65
- if (!summary.inspectable) return { dangerous: true, reason: 'Dangerous call arguments could not be inspected' };
66
- const byCommand = normalized.commandPatterns.some(pattern => pattern.test(summary.command));
67
- const byPath = normalized.pathPatterns.some(pattern => pattern.test(summary.path));
68
- return { dangerous: byCommand || byPath || (!summary.command && !summary.path), reason: 'Dangerous tool call requires a host decision' };
69
- }
70
-
71
- function suggestionQuestion(exec) {
72
- return {
73
- state: summarizeExecution(exec),
74
- questions: { verdict: {
75
- type: 'choice', instructions: 'Classify this dangerous tool call. Never grant a permission the host has not already granted.',
76
- criteria: { allow: 'No additional intervention is recommended', ask: 'Ask the host user for approval', deny: 'Reject this dangerous call' },
77
- } },
78
- };
79
- }
80
-
81
- export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS_RULES, minConfidence = MIN_CONFIDENCE } = {}) {
82
- if (typeof evaluate !== 'function') throw new Error('Danger gate requires an evaluator');
83
- const resolveRules = async () => {
84
- if (typeof rules !== 'function') return rules;
85
- try { return (await rules()) ?? DEFAULT_DANGEROUS_RULES; } catch { return DEFAULT_DANGEROUS_RULES; }
86
- };
87
- // The confidence floor may be a fixed number or a live resolver (config-backed),
88
- // mirroring `rules`, so changing it takes effect without reloading the plugin.
89
- // An invalid or failed resolution falls back to the built-in floor rather than
90
- // to 0 — a broken config must never turn the gate into "block every deny".
91
- const resolveMinConfidence = async () => {
92
- if (typeof minConfidence !== 'function') return isProbability(minConfidence) ? minConfidence : MIN_CONFIDENCE;
93
- try {
94
- const value = await minConfidence();
95
- return isProbability(value) ? value : MIN_CONFIDENCE;
96
- } catch { return MIN_CONFIDENCE; }
97
- };
98
- const deniedCalls = new WeakSet();
99
- const evaluatedCalls = new WeakSet();
100
- const record = (exec, outcome, detail = {}) => {
101
- const id = exec?.agent?.session?.id;
102
- if (!sessions || typeof id !== 'string') return;
103
- try { sessions.record(id, 'gate', outcome); } catch { /* metrics cannot change the security decision */ }
104
- if (typeof sessions.log !== 'function') return;
105
- // Attach the same redacted, bounded command/path summary that was sent to the
106
- // backend, so a human reviewing the log sees WHAT was gated (e.g. the actual
107
- // `rm -rf ...`), not just the tool name. Secrets are already stripped by
108
- // summarizeExecution; session-state caps the length for storage.
109
- const summary = summarizeExecution(exec);
110
- const args = {};
111
- if (summary.command) args.command = summary.command;
112
- if (summary.path) args.path = summary.path;
113
- try { sessions.log(id, { kind: 'gate', outcome, tool: exec?.name, ...args, ...detail }); }
114
- catch { /* logging is best effort */ }
115
- };
116
- // Two-tier model: the gate intervenes ONLY on a high-confidence `deny`
117
- // (returned as kind:'deny', enforced monotonically through tools.guard,
118
- // independent of the host approval policy). Every other outcome — a low-
119
- // confidence verdict, a high-confidence allow/ask, an invalid response, or a
120
- // backend failure — is NOT an intervention: the call proceeds on its original
121
- // path (next()). The gate no longer emits `ask`, so it never routes through
122
- // host approval and can no longer be mis-rendered as "the user rejected".
123
- // `passThrough` records the non-intervention for observability and returns
124
- // undefined so preExecute falls through to next().
125
- const passThrough = (exec, outcome, detail = {}) => {
126
- record(exec, outcome, { action: 'pass', ...detail });
127
- if (exec && typeof exec === 'object') evaluatedCalls.add(exec);
128
- return undefined;
129
- };
130
- return {
131
- classify: exec => classifyDangerous(exec, typeof rules === 'function' ? DEFAULT_DANGEROUS_RULES : rules),
132
- guardReason(exec) {
133
- if (!exec || !deniedCalls.has(exec)) return undefined;
134
- deniedCalls.delete(exec);
135
- return 'Dangerous call rejected by decision policy';
136
- },
137
- observeResult(exec, result) {
138
- if (!evaluatedCalls.has(exec) || !sessions || typeof sessions.recordActual !== 'function') return;
139
- const id = exec?.agent?.session?.id;
140
- if (typeof id !== 'string') return;
141
- try { sessions.recordActual(id, result?.isError ? 'deny' : 'allow'); } catch { /* telemetry is best effort */ }
142
- evaluatedCalls.delete(exec);
143
- },
144
- async evaluate(exec) {
145
- let result;
146
- try {
147
- result = await evaluate(suggestionQuestion(exec), { signal: exec.signal });
148
- } catch (error) {
149
- // Backend failure: no verdict, so do not intervene (pass through).
150
- return passThrough(exec, 'error', { reason: typeof error?.reason === 'string' ? error.reason : 'unreachable' });
151
- }
152
- const answer = result?.answers?.verdict;
153
- const choice = answer?.choice;
154
- if (!['allow', 'ask', 'deny'].includes(choice)) return passThrough(exec, 'error', { reason: 'invalid-response' });
155
- const minConfidenceNow = await resolveMinConfidence();
156
- const confident = typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) && answer.confidence >= minConfidenceNow;
157
- // Only a high-confidence deny blocks the call.
158
- if (choice === 'deny' && confident) {
159
- record(exec, 'deny', { suggestion: 'deny', action: 'deny' });
160
- if (exec && typeof exec === 'object') deniedCalls.add(exec);
161
- return { kind: 'deny', reason: 'Dangerous call rejected by decision policy', modelSuggestion: choice };
162
- }
163
- // Uncertain (low confidence) or a non-deny verdict → do not intervene.
164
- const detail = { suggestion: choice };
165
- if (!confident) { detail.reason = 'low-confidence'; if (typeof answer.confidence === 'number' && Number.isFinite(answer.confidence)) detail.confidence = answer.confidence; }
166
- return passThrough(exec, 'allow', detail);
167
- },
168
- async preExecute(exec, next) {
169
- const classification = classifyDangerous(exec, await resolveRules());
170
- if (!classification.dangerous) return next();
171
- const id = exec?.agent?.session?.id;
172
- if (sessions && typeof id === 'string') {
173
- try {
174
- // A deliberate user toggle bypasses the gate entirely (no record).
175
- if (sessions.snapshot(id).enabled === false) return next();
176
- } catch { passThrough(exec, 'error', { reason: 'unreachable' }); return next(); }
177
- }
178
- const decision = await this.evaluate(exec);
179
- return decision ?? next();
180
- },
181
- };
182
- }