dsh-decision-layer 0.4.0

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/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Decision Layer",
4
+ "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, and loop guard backed by a pluggable adjudication model."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "决策层",
4
+ "description": "为 DeepSeek Harness agent 循环提供的结构化决策层:危险调用门控、产出自检与防循环,由可插拔的裁决模型驱动。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "dsh-decision-layer",
3
+ "version": "0.4.0",
4
+ "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, and loop guard backed by a pluggable adjudication model.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "deepseek-harness-plugin",
10
+ "decision-layer",
11
+ "ai-agents"
12
+ ],
13
+ "author": "xbzbing",
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "icon": "./icon.svg",
17
+ "main": "./service/dsh.mjs",
18
+ "files": [
19
+ "service",
20
+ "skills",
21
+ "lib/client.js",
22
+ "locale/*.json",
23
+ "icon.svg",
24
+ "cordis.patch.yml",
25
+ "README.md",
26
+ "README.en.md",
27
+ "LICENSE"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20.11"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/xbzbing/dsh-decision-layer.git"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test tests/*.test.mjs",
38
+ "build:client": "tsc -p tsconfig.client.json && node scripts/build-client.mjs"
39
+ },
40
+ "exports": {
41
+ ".": "./service/dsh.mjs",
42
+ "./client": "./lib/client.js",
43
+ "./locale/*.json": "./locale/*.json",
44
+ "./package.json": "./package.json"
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/cordis": "~4.0.4",
48
+ "@deepseek-ai/dsh": ">=0.1.7-rc.2"
49
+ },
50
+ "devDependencies": {
51
+ "@deepseek-ai/dsh-client-locale": "0.1.7-rc.2",
52
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.1.7-rc.2",
53
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.2",
54
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
55
+ "@testing-library/react": "^16.3.3",
56
+ "@types/react": "~18.3.1",
57
+ "@types/react-dom": "~18.3.0",
58
+ "esbuild": "^0.25.12",
59
+ "jsdom": "^26.1.0",
60
+ "react": "^18.2.0",
61
+ "react-dom": "^18.3.1",
62
+ "typescript": "~5.9.3"
63
+ },
64
+ "dsh": {
65
+ "bundle": {
66
+ "patch": "./cordis.patch.yml"
67
+ },
68
+ "client": {
69
+ "inject": [
70
+ "@deepseek-ai/dsh-client-ui-renderer",
71
+ "@deepseek-ai/dsh-client-ui-slots",
72
+ "@deepseek-ai/dsh-client-locale",
73
+ "@deepseek-ai/dsh-client-ui-plugin-manager"
74
+ ],
75
+ "platform": "web"
76
+ }
77
+ }
78
+ }
File without changes
@@ -0,0 +1,127 @@
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
+ }
@@ -0,0 +1,203 @@
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
+ function checkedUrl(value) {
33
+ if (!value) return '';
34
+ let url;
35
+ try { url = new URL(value); } catch { throw new Error('Invalid URL'); }
36
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
37
+ throw new Error('Invalid URL: expected an HTTP(S) address without credentials, query or fragment');
38
+ }
39
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
40
+ }
41
+
42
+ function validRules(value) {
43
+ if (!safeObject(value)) return undefined;
44
+ const fields = ['addToolNames', 'removeToolNames', 'addCommandPatterns', 'addPathPatterns'];
45
+ if (Object.keys(value).some(key => !fields.includes(key))) throw new Error('Invalid danger rules');
46
+ const rules = {};
47
+ for (const field of fields) {
48
+ if (value[field] !== undefined && (!Array.isArray(value[field]) || value[field].length > 64 || value[field].some(item => typeof item !== 'string' || !item.trim() || item.length > 128))) {
49
+ throw new Error('Invalid danger rules');
50
+ }
51
+ if (value[field] !== undefined) rules[field] = value[field].map(item => item.trim());
52
+ }
53
+ return rules;
54
+ }
55
+
56
+ function validCheck(value) {
57
+ if (!safeObject(value)) return undefined;
58
+ if (Object.keys(value).some(key => !['mode', 'rubric', 'lowScoreThreshold'].includes(key))) throw new Error('Invalid self-check settings');
59
+ const check = {};
60
+ if (value.mode !== undefined) {
61
+ if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid self-check settings');
62
+ check.mode = value.mode;
63
+ }
64
+ if (value.rubric !== undefined) {
65
+ if (!Array.isArray(value.rubric) || value.rubric.length > 10 || value.rubric.some(item => typeof item !== 'string' || !item.trim() || item.length > 256)) {
66
+ throw new Error('Invalid self-check settings');
67
+ }
68
+ check.rubric = value.rubric.map(item => item.trim());
69
+ }
70
+ if (value.lowScoreThreshold !== undefined) {
71
+ if (!Number.isSafeInteger(value.lowScoreThreshold) || value.lowScoreThreshold < 0 || value.lowScoreThreshold > 9) throw new Error('Invalid self-check settings');
72
+ check.lowScoreThreshold = value.lowScoreThreshold;
73
+ }
74
+ return check;
75
+ }
76
+
77
+ function validNarrow(value) {
78
+ if (!safeObject(value)) return undefined;
79
+ if (Object.keys(value).some(key => !['mode', 'threshold', 'keepPrefixes', 'maxCandidates'].includes(key))) throw new Error('Invalid narrowing settings');
80
+ const narrow = {};
81
+ if (value.mode !== undefined) {
82
+ if (value.mode !== 'observe' && value.mode !== 'enforce') throw new Error('Invalid narrowing settings');
83
+ narrow.mode = value.mode;
84
+ }
85
+ if (value.threshold !== undefined) {
86
+ if (typeof value.threshold !== 'number' || !Number.isFinite(value.threshold) || value.threshold < 0 || value.threshold > 1) throw new Error('Invalid narrowing settings');
87
+ narrow.threshold = value.threshold;
88
+ }
89
+ // Tool name prefixes that are always kept and never judged for relevance, on
90
+ // top of the built-in core tools. An explicit empty array clears the list; an
91
+ // absent field lets narrowing fall back to its own default (mcp__openviking).
92
+ if (value.keepPrefixes !== undefined) {
93
+ if (!Array.isArray(value.keepPrefixes) || value.keepPrefixes.length > 32 ||
94
+ value.keepPrefixes.some(item => typeof item !== 'string' || !item.trim() || item.length > 128)) {
95
+ throw new Error('Invalid narrowing settings');
96
+ }
97
+ narrow.keepPrefixes = value.keepPrefixes.map(item => item.trim());
98
+ }
99
+ // Optional-candidate ceiling: above this count, enforce is downgraded to
100
+ // observe so a large tool surface is never mass-pruned. 1–200; unset uses the
101
+ // narrowing default (20).
102
+ if (value.maxCandidates !== undefined) {
103
+ if (!Number.isSafeInteger(value.maxCandidates) || value.maxCandidates < 1 || value.maxCandidates > 200) throw new Error('Invalid narrowing settings');
104
+ narrow.maxCandidates = value.maxCandidates;
105
+ }
106
+ return narrow;
107
+ }
108
+
109
+ // Per-feature enable flags for the three decision points. Every flag defaults to
110
+ // enabled; only an explicit `false` disables its decision point.
111
+ export const FEATURE_KEYS = Object.freeze(['gate', 'check', 'narrow']);
112
+
113
+ function validFeatures(value) {
114
+ if (!safeObject(value)) return undefined;
115
+ if (Object.keys(value).some(key => !FEATURE_KEYS.includes(key))) throw new Error('Invalid feature settings');
116
+ const features = {};
117
+ for (const key of FEATURE_KEYS) {
118
+ if (value[key] !== undefined) {
119
+ if (typeof value[key] !== 'boolean') throw new Error('Invalid feature settings');
120
+ features[key] = value[key];
121
+ }
122
+ }
123
+ return features;
124
+ }
125
+
126
+ // Resolve one decision point's enablement, defaulting to enabled when unset.
127
+ export function featureEnabled(features, key) {
128
+ return !(safeObject(features) && features[key] === false);
129
+ }
130
+
131
+ function view(stored) {
132
+ const result = {
133
+ url: safeText(stored.url),
134
+ model: safeText(stored.model),
135
+ apiKeySet: Boolean(safeText(stored.apiKey)),
136
+ httpApprovedUrl: safeText(stored.httpApprovedUrl),
137
+ };
138
+ if (stored.dangerRules !== undefined) result.dangerRules = validRules(stored.dangerRules);
139
+ if (stored.checkSettings !== undefined) result.checkSettings = validCheck(stored.checkSettings);
140
+ if (stored.narrowSettings !== undefined) result.narrowSettings = validNarrow(stored.narrowSettings);
141
+ if (stored.features !== undefined) result.features = validFeatures(stored.features);
142
+ return result;
143
+ }
144
+
145
+ export async function loadConfig(path = configPath()) {
146
+ return view(await readStored(path));
147
+ }
148
+
149
+ export async function resolveConfig({ path = configPath(), env = process.env } = {}) {
150
+ const stored = await readStored(path);
151
+ const url = checkedUrl(safeText(stored.url) || safeText(env.DSH_DECISION_BASE_URL) || safeText(env.TYPESAFE_BASE_URL) || DEFAULT_URL);
152
+ const resolved = {
153
+ url,
154
+ apiKey: safeText(stored.apiKey) || safeText(env.DSH_DECISION_API_KEY) || safeText(env.TYPESAFE_API_KEY),
155
+ model: safeText(stored.model) || safeText(env.DSH_DECISION_MODEL) || DEFAULT_MODEL,
156
+ httpApprovedUrl: safeText(stored.httpApprovedUrl),
157
+ };
158
+ if (stored.dangerRules !== undefined) resolved.dangerRules = validRules(stored.dangerRules);
159
+ if (stored.checkSettings !== undefined) resolved.checkSettings = validCheck(stored.checkSettings);
160
+ if (stored.narrowSettings !== undefined) resolved.narrowSettings = validNarrow(stored.narrowSettings);
161
+ if (stored.features !== undefined) resolved.features = validFeatures(stored.features);
162
+ return resolved;
163
+ }
164
+
165
+ export async function saveConfig(input, path = configPath()) {
166
+ if (!safeObject(input) || Object.keys(input).some(key => !['url', 'apiKey', 'model', 'confirmHttpUrl', 'dangerRules', 'checkSettings', 'narrowSettings', 'features'].includes(key))) {
167
+ throw new Error('Invalid configuration fields');
168
+ }
169
+ for (const key of ['url', 'apiKey', 'model', 'confirmHttpUrl']) {
170
+ if (input[key] !== undefined && typeof input[key] !== 'string') throw new Error(`Invalid ${key}`);
171
+ }
172
+ const previous = await readStored(path);
173
+ const url = checkedUrl(safeText(input.url === undefined ? previous.url : input.url));
174
+ const apiKey = safeText(input.apiKey === undefined ? previous.apiKey : input.apiKey);
175
+ const model = safeText(input.model === undefined ? previous.model : input.model);
176
+ if (model.length > 128 || /[\r\n]/.test(input.model ?? '') || /[\r\n]/.test(input.apiKey ?? '')) {
177
+ throw new Error('Invalid model or API key');
178
+ }
179
+ const dangerRules = input.dangerRules === undefined ? previous.dangerRules : validRules(input.dangerRules);
180
+ const checkSettings = input.checkSettings === undefined ? previous.checkSettings : validCheck(input.checkSettings);
181
+ const narrowSettings = input.narrowSettings === undefined ? previous.narrowSettings : validNarrow(input.narrowSettings);
182
+ const features = input.features === undefined ? previous.features : validFeatures(input.features);
183
+ const confirmed = url.startsWith('http://') && input.confirmHttpUrl !== undefined &&
184
+ checkedUrl(safeText(input.confirmHttpUrl)) === url;
185
+ if (url.startsWith('http://') && !confirmed && previous.httpApprovedUrl !== url) {
186
+ throw new Error('HTTP URL requires explicit confirmation');
187
+ }
188
+ const next = { url, apiKey, model, httpApprovedUrl: url.startsWith('http://') ? url : '' };
189
+ if (dangerRules !== undefined) next.dangerRules = dangerRules;
190
+ if (checkSettings !== undefined) next.checkSettings = checkSettings;
191
+ if (narrowSettings !== undefined) next.narrowSettings = narrowSettings;
192
+ if (features !== undefined) next.features = features;
193
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
194
+ const temporary = `${path}.${randomUUID()}.tmp`;
195
+ try {
196
+ await writeFile(temporary, `${JSON.stringify(next)}\n`, { mode: 0o600, flag: 'wx' });
197
+ await chmod(temporary, 0o600);
198
+ await rename(temporary, path);
199
+ } finally {
200
+ await rm(temporary, { force: true });
201
+ }
202
+ return view(next);
203
+ }
@@ -0,0 +1,143 @@
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.7;
15
+
16
+ export const DEFAULT_DANGEROUS_RULES = Object.freeze({
17
+ toolNames: Object.freeze(['bash', 'pwsh', 'shell', 'exec', 'terminal', 'run_command', 'write', 'edit', 'str_replace_editor', 'file_write', 'write_file']),
18
+ commandPatterns: DEFAULT_COMMAND_PATTERNS,
19
+ pathPatterns: DEFAULT_PATH_PATTERNS,
20
+ });
21
+
22
+ const redact = value => value.slice(0, MAX_METADATA_LENGTH)
23
+ .replace(/(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+/gi, '$1=[redacted]')
24
+ .replace(/(password|passwd|secret|token|api[_-]?key)\s+\S+/gi, '$1 [redacted]');
25
+ const literalPattern = value => new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
26
+ const boundedList = value => Array.isArray(value) ? value.filter(item => typeof item === 'string' && item.trim() && item.length <= MAX_PATTERN_LENGTH).slice(0, MAX_RULES) : [];
27
+
28
+ function collectMetadata(value, depth = 0, commands = [], paths = []) {
29
+ if (depth > 4 || value === null || typeof value !== 'object') return;
30
+ for (const [key, item] of Object.entries(value)) {
31
+ const normalized = key.toLowerCase();
32
+ if (typeof item === 'string' && COMMAND_KEYS.has(normalized)) commands.push(redact(item));
33
+ else if (typeof item === 'string' && PATH_KEYS.has(normalized)) paths.push(redact(item));
34
+ else if (item && typeof item === 'object' && !['content', 'body', 'data', 'metadata', 'headers', 'env', 'value'].includes(normalized)) {
35
+ collectMetadata(item, depth + 1, commands, paths);
36
+ }
37
+ }
38
+ return { commands, paths };
39
+ }
40
+
41
+ export function summarizeExecution(exec) {
42
+ const commands = [];
43
+ const paths = [];
44
+ const collected = collectMetadata(exec?.arguments, 0, commands, paths);
45
+ return { tool: typeof exec?.name === 'string' ? exec.name.slice(0, 128) : '',
46
+ command: commands.join('\n').slice(0, MAX_METADATA_LENGTH), path: paths.join('\n').slice(0, MAX_METADATA_LENGTH),
47
+ inspectable: Boolean(collected) };
48
+ }
49
+
50
+ export function normalizeDangerRules(input = {}) {
51
+ const addTools = boundedList(input.addToolNames);
52
+ const removeTools = new Set(boundedList(input.removeToolNames));
53
+ const tools = [...new Set([...DEFAULT_DANGEROUS_RULES.toolNames, ...addTools])].filter(tool => !removeTools.has(tool));
54
+ const commands = [...DEFAULT_COMMAND_PATTERNS, ...boundedList(input.addCommandPatterns).map(literalPattern)];
55
+ const paths = [...DEFAULT_PATH_PATTERNS, ...boundedList(input.addPathPatterns).map(literalPattern)];
56
+ return { toolNames: tools, commandPatterns: commands, pathPatterns: paths };
57
+ }
58
+
59
+ export function classifyDangerous(exec, rules = DEFAULT_DANGEROUS_RULES) {
60
+ const normalized = Array.isArray(rules.toolNames) ? rules : normalizeDangerRules(rules);
61
+ const summary = summarizeExecution(exec);
62
+ if (!normalized.toolNames.includes(summary.tool)) return { dangerous: false, reason: 'Tool is outside the dangerous-call rules' };
63
+ if (!summary.inspectable) return { dangerous: true, reason: 'Dangerous call arguments could not be inspected' };
64
+ const byCommand = normalized.commandPatterns.some(pattern => pattern.test(summary.command));
65
+ const byPath = normalized.pathPatterns.some(pattern => pattern.test(summary.path));
66
+ return { dangerous: byCommand || byPath || (!summary.command && !summary.path), reason: 'Dangerous tool call requires a host decision' };
67
+ }
68
+
69
+ function suggestionQuestion(exec) {
70
+ return {
71
+ state: summarizeExecution(exec),
72
+ questions: { verdict: {
73
+ type: 'choice', instructions: 'Classify this dangerous tool call. Never grant a permission the host has not already granted.',
74
+ criteria: { allow: 'No additional intervention is recommended', ask: 'Ask the host user for approval', deny: 'Reject this dangerous call' },
75
+ } },
76
+ };
77
+ }
78
+
79
+ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS_RULES, minConfidence = MIN_CONFIDENCE } = {}) {
80
+ if (typeof evaluate !== 'function') throw new Error('Danger gate requires an evaluator');
81
+ const resolveRules = async () => {
82
+ if (typeof rules !== 'function') return rules;
83
+ try { return (await rules()) ?? DEFAULT_DANGEROUS_RULES; } catch { return DEFAULT_DANGEROUS_RULES; }
84
+ };
85
+ const deniedCalls = new WeakSet();
86
+ const evaluatedCalls = new WeakSet();
87
+ const record = (exec, outcome, detail = {}) => {
88
+ const id = exec?.agent?.session?.id;
89
+ if (!sessions || typeof id !== 'string') return;
90
+ try { sessions.record(id, 'gate', outcome); } catch { /* metrics cannot change the security decision */ }
91
+ if (typeof sessions.log !== 'function') return;
92
+ try { sessions.log(id, { kind: 'gate', outcome, tool: exec?.name, ...detail }); }
93
+ catch { /* logging is best effort */ }
94
+ };
95
+ const fallback = (exec, reason = 'unavailable', detail = {}) => {
96
+ record(exec, 'error', { action: 'ask', reason, ...detail });
97
+ if (exec && typeof exec === 'object') evaluatedCalls.add(exec);
98
+ return { kind: 'ask', reason: 'Dangerous call requires host approval' };
99
+ };
100
+ return {
101
+ classify: exec => classifyDangerous(exec, typeof rules === 'function' ? DEFAULT_DANGEROUS_RULES : rules),
102
+ guardReason(exec) {
103
+ if (!exec || !deniedCalls.has(exec)) return undefined;
104
+ deniedCalls.delete(exec);
105
+ return 'Dangerous call rejected by decision policy';
106
+ },
107
+ observeResult(exec, result) {
108
+ if (!evaluatedCalls.has(exec) || !sessions || typeof sessions.recordActual !== 'function') return;
109
+ const id = exec?.agent?.session?.id;
110
+ if (typeof id !== 'string') return;
111
+ try { sessions.recordActual(id, result?.isError ? 'deny' : 'allow'); } catch { /* telemetry is best effort */ }
112
+ evaluatedCalls.delete(exec);
113
+ },
114
+ async evaluate(exec) {
115
+ if (exec && typeof exec === 'object') evaluatedCalls.add(exec);
116
+ let result;
117
+ try {
118
+ result = await evaluate(suggestionQuestion(exec), { signal: exec.signal });
119
+ } catch (error) { return fallback(exec, typeof error?.reason === 'string' ? error.reason : 'unreachable'); }
120
+ const answer = result?.answers?.verdict;
121
+ const choice = answer?.choice;
122
+ if (!['allow', 'ask', 'deny'].includes(choice)) return fallback(exec, 'invalid-response');
123
+ if (typeof answer.confidence !== 'number' || answer.confidence < minConfidence) return fallback(exec, 'low-confidence', typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) ? { confidence: answer.confidence } : {});
124
+ if (choice === 'deny') { record(exec, 'deny', { suggestion: 'deny', action: 'deny' }); if (exec && typeof exec === 'object') deniedCalls.add(exec); return { kind: 'deny', reason: 'Dangerous call rejected by decision policy', modelSuggestion: choice }; }
125
+ record(exec, choice, { suggestion: choice, action: 'ask' });
126
+ return { kind: 'ask', reason: 'Dangerous call requires host approval', modelSuggestion: choice };
127
+ },
128
+ async preExecute(exec, next) {
129
+ const classification = classifyDangerous(exec, await resolveRules());
130
+ if (!classification.dangerous) return next();
131
+ const id = exec?.agent?.session?.id;
132
+ if (sessions && typeof id === 'string') {
133
+ try {
134
+ const snapshot = sessions.snapshot(id);
135
+ // A deliberate user toggle bypasses the gate; involuntary capacity overflow must not fail-open.
136
+ if (snapshot.enabled === false && !snapshot.capacityExceeded) return next();
137
+ if (snapshot.capacityExceeded) return fallback(exec, 'capacity');
138
+ } catch { return fallback(exec, 'unreachable'); }
139
+ }
140
+ return this.evaluate(exec);
141
+ },
142
+ };
143
+ }