dsh-decision-layer 0.7.1 → 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 (57) hide show
  1. package/README.en.md +8 -8
  2. package/README.md +10 -10
  3. package/lib/client.js +39 -14
  4. package/lib/host/backend.d.ts +15 -0
  5. package/lib/host/backend.js +149 -0
  6. package/lib/host/config-store.d.ts +12 -0
  7. package/lib/host/config-store.js +254 -0
  8. package/lib/host/danger-gate.d.ts +45 -0
  9. package/lib/host/danger-gate.js +196 -0
  10. package/lib/host/excerpt.d.ts +25 -0
  11. package/lib/host/excerpt.js +62 -0
  12. package/lib/host/index.d.ts +6 -0
  13. package/lib/host/index.js +290 -0
  14. package/lib/host/log-analyze.d.ts +121 -0
  15. package/lib/host/log-analyze.js +254 -0
  16. package/lib/host/log-read.d.ts +4 -0
  17. package/lib/host/log-read.js +34 -0
  18. package/lib/host/log-store.d.ts +15 -0
  19. package/lib/host/log-store.js +98 -0
  20. package/lib/host/loop-guard.d.ts +10 -0
  21. package/lib/host/loop-guard.js +52 -0
  22. package/lib/host/manager-api.d.ts +20 -0
  23. package/lib/host/manager-api.js +174 -0
  24. package/lib/host/narrowing.d.ts +48 -0
  25. package/lib/host/narrowing.js +213 -0
  26. package/lib/host/quality-trend.d.ts +14 -0
  27. package/lib/host/quality-trend.js +22 -0
  28. package/lib/host/self-check.d.ts +27 -0
  29. package/lib/host/self-check.js +104 -0
  30. package/lib/host/session-state.d.ts +19 -0
  31. package/lib/host/session-state.js +288 -0
  32. package/lib/host/state-store.d.ts +14 -0
  33. package/lib/host/state-store.js +109 -0
  34. package/lib/host/steer-once.d.ts +13 -0
  35. package/lib/host/steer-once.js +25 -0
  36. package/lib/host/task-completion.d.ts +29 -0
  37. package/lib/host/task-completion.js +148 -0
  38. package/lib/host/types.d.ts +343 -0
  39. package/lib/host/types.js +11 -0
  40. package/package.json +17 -10
  41. package/service/.gitkeep +0 -0
  42. package/service/backend.mjs +0 -127
  43. package/service/config-store.mjs +0 -311
  44. package/service/danger-gate.mjs +0 -182
  45. package/service/dsh.mjs +0 -288
  46. package/service/log-analyze.mjs +0 -257
  47. package/service/log-read.mjs +0 -27
  48. package/service/log-store.mjs +0 -82
  49. package/service/loop-guard.mjs +0 -51
  50. package/service/manager-api.mjs +0 -137
  51. package/service/narrowing.mjs +0 -226
  52. package/service/quality-trend.mjs +0 -29
  53. package/service/self-check.mjs +0 -131
  54. package/service/session-state.mjs +0 -260
  55. package/service/state-store.mjs +0 -100
  56. package/service/steer-once.mjs +0 -25
  57. package/service/task-completion.mjs +0 -163
@@ -0,0 +1,254 @@
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
+ import { isObject as safeObject, isProbability as probability } from "./types.js";
6
+ const DEFAULT_URL = "https://api.typesafe.ai";
7
+ const DEFAULT_MODEL = "jev-latest";
8
+ function configPath(env = process.env) {
9
+ return env.DSH_DECISION_CONFIG_PATH?.trim() || join(homedir(), ".config", "dsh-decision-layer", "config.json");
10
+ }
11
+ async function readStored(path) {
12
+ try {
13
+ const value = JSON.parse(await readFile(path, "utf8"));
14
+ if (!safeObject(value)) throw new Error("Invalid decision configuration");
15
+ return value;
16
+ } catch (error) {
17
+ if (error?.code === "ENOENT") return {};
18
+ throw error;
19
+ }
20
+ }
21
+ function safeText(value) {
22
+ return typeof value === "string" ? value.trim() : "";
23
+ }
24
+ function checkedUrl(value) {
25
+ if (!value) return "";
26
+ let url;
27
+ try {
28
+ url = new URL(value);
29
+ } catch {
30
+ throw new Error("Invalid URL");
31
+ }
32
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
33
+ throw new Error("Invalid URL: expected an HTTP(S) address without credentials, query or fragment");
34
+ }
35
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
36
+ }
37
+ const RULE_FIELDS = ["addToolNames", "removeToolNames", "addCommandPatterns", "addPathPatterns"];
38
+ function validRules(value) {
39
+ if (!safeObject(value)) return void 0;
40
+ if (Object.keys(value).some((key) => !RULE_FIELDS.includes(key))) throw new Error("Invalid danger rules");
41
+ const rules = {};
42
+ for (const field of RULE_FIELDS) {
43
+ const raw = value[field];
44
+ if (raw !== void 0 && (!Array.isArray(raw) || raw.length > 64 || raw.some((item) => typeof item !== "string" || !item.trim() || item.length > 128))) {
45
+ throw new Error("Invalid danger rules");
46
+ }
47
+ if (raw !== void 0) rules[field] = raw.map((item) => item.trim());
48
+ }
49
+ return rules;
50
+ }
51
+ function validGate(value) {
52
+ if (!safeObject(value)) return void 0;
53
+ if (Object.keys(value).some((key) => key !== "minConfidence")) throw new Error("Invalid gate settings");
54
+ const gate = {};
55
+ if (value.minConfidence !== void 0) {
56
+ if (!probability(value.minConfidence)) throw new Error("Invalid gate settings");
57
+ gate.minConfidence = value.minConfidence;
58
+ }
59
+ return gate;
60
+ }
61
+ const CHECK_FIELDS = ["mode", "rubric", "lowScoreThreshold", "minConfidence", "trendMinConfidence", "trendRun", "trendSevereRun"];
62
+ function validCheck(value) {
63
+ if (!safeObject(value)) return void 0;
64
+ if (Object.keys(value).some((key) => !CHECK_FIELDS.includes(key))) throw new Error("Invalid self-check settings");
65
+ const check = {};
66
+ if (value.mode !== void 0) {
67
+ if (value.mode !== "observe" && value.mode !== "steer") throw new Error("Invalid self-check settings");
68
+ check.mode = value.mode;
69
+ }
70
+ if (value.rubric !== void 0) {
71
+ if (!Array.isArray(value.rubric) || value.rubric.length > 10 || value.rubric.some((item) => typeof item !== "string" || !item.trim() || item.length > 256)) {
72
+ throw new Error("Invalid self-check settings");
73
+ }
74
+ check.rubric = value.rubric.map((item) => item.trim());
75
+ }
76
+ if (value.lowScoreThreshold !== void 0) {
77
+ if (!Number.isSafeInteger(value.lowScoreThreshold) || value.lowScoreThreshold < 0 || value.lowScoreThreshold > 9) throw new Error("Invalid self-check settings");
78
+ check.lowScoreThreshold = value.lowScoreThreshold;
79
+ }
80
+ if (value.minConfidence !== void 0) {
81
+ if (!probability(value.minConfidence)) throw new Error("Invalid self-check settings");
82
+ check.minConfidence = value.minConfidence;
83
+ }
84
+ if (value.trendMinConfidence !== void 0) {
85
+ if (!probability(value.trendMinConfidence)) throw new Error("Invalid self-check settings");
86
+ check.trendMinConfidence = value.trendMinConfidence;
87
+ }
88
+ if (value.trendRun !== void 0) {
89
+ if (!Number.isSafeInteger(value.trendRun) || value.trendRun < 2 || value.trendRun > 10) throw new Error("Invalid self-check settings");
90
+ check.trendRun = value.trendRun;
91
+ }
92
+ if (value.trendSevereRun !== void 0) {
93
+ if (!Number.isSafeInteger(value.trendSevereRun) || value.trendSevereRun < 2 || value.trendSevereRun > 20) throw new Error("Invalid self-check settings");
94
+ check.trendSevereRun = value.trendSevereRun;
95
+ }
96
+ if (check.trendSevereRun !== void 0) {
97
+ const warn = check.trendRun ?? 3;
98
+ if (check.trendSevereRun <= warn) throw new Error("Invalid self-check settings");
99
+ }
100
+ return check;
101
+ }
102
+ const NARROW_FIELDS = ["mode", "threshold", "keepPrefixes", "maxCandidates", "batchMode"];
103
+ function validNarrow(value) {
104
+ if (!safeObject(value)) return void 0;
105
+ if (Object.keys(value).some((key) => !NARROW_FIELDS.includes(key))) throw new Error("Invalid narrowing settings");
106
+ const narrow = {};
107
+ if (value.mode !== void 0) {
108
+ if (value.mode !== "observe" && value.mode !== "enforce") throw new Error("Invalid narrowing settings");
109
+ narrow.mode = value.mode;
110
+ }
111
+ if (value.threshold !== void 0) {
112
+ if (!probability(value.threshold)) throw new Error("Invalid narrowing settings");
113
+ narrow.threshold = value.threshold;
114
+ }
115
+ if (value.keepPrefixes !== void 0) {
116
+ if (!Array.isArray(value.keepPrefixes) || value.keepPrefixes.length > 32 || value.keepPrefixes.some((item) => typeof item !== "string" || !item.trim() || item.length > 128)) {
117
+ throw new Error("Invalid narrowing settings");
118
+ }
119
+ narrow.keepPrefixes = value.keepPrefixes.map((item) => item.trim());
120
+ }
121
+ if (value.maxCandidates !== void 0) {
122
+ if (!Number.isSafeInteger(value.maxCandidates) || value.maxCandidates < 1 || value.maxCandidates > 200) throw new Error("Invalid narrowing settings");
123
+ narrow.maxCandidates = value.maxCandidates;
124
+ }
125
+ if (value.batchMode !== void 0) {
126
+ if (value.batchMode !== "auto" && value.batchMode !== "single" && value.batchMode !== "split") throw new Error("Invalid narrowing settings");
127
+ narrow.batchMode = value.batchMode;
128
+ }
129
+ return narrow;
130
+ }
131
+ const FEATURE_KEYS = Object.freeze(["gate", "check", "narrow", "complete"]);
132
+ function validComplete(value) {
133
+ if (!safeObject(value)) return void 0;
134
+ if (Object.keys(value).some((key) => !["mode", "minConditions", "minConfidence"].includes(key))) throw new Error("Invalid task-completion settings");
135
+ const complete = {};
136
+ if (value.mode !== void 0) {
137
+ if (value.mode !== "observe" && value.mode !== "steer") throw new Error("Invalid task-completion settings");
138
+ complete.mode = value.mode;
139
+ }
140
+ if (value.minConditions !== void 0) {
141
+ if (!Number.isSafeInteger(value.minConditions) || value.minConditions < 2 || value.minConditions > 20) throw new Error("Invalid task-completion settings");
142
+ complete.minConditions = value.minConditions;
143
+ }
144
+ if (value.minConfidence !== void 0) {
145
+ if (!probability(value.minConfidence)) throw new Error("Invalid task-completion settings");
146
+ complete.minConfidence = value.minConfidence;
147
+ }
148
+ return complete;
149
+ }
150
+ function validFeatures(value) {
151
+ if (!safeObject(value)) return void 0;
152
+ if (Object.keys(value).some((key) => !FEATURE_KEYS.includes(key))) throw new Error("Invalid feature settings");
153
+ const features = {};
154
+ for (const key of FEATURE_KEYS) {
155
+ if (value[key] !== void 0) {
156
+ if (typeof value[key] !== "boolean") throw new Error("Invalid feature settings");
157
+ features[key] = value[key];
158
+ }
159
+ }
160
+ return features;
161
+ }
162
+ function featureEnabled(features, key) {
163
+ return !(safeObject(features) && features[key] === false);
164
+ }
165
+ function view(stored) {
166
+ const result = {
167
+ url: safeText(stored.url),
168
+ model: safeText(stored.model),
169
+ apiKeySet: Boolean(safeText(stored.apiKey)),
170
+ httpApprovedUrl: safeText(stored.httpApprovedUrl)
171
+ };
172
+ if (stored.dangerRules !== void 0) result.dangerRules = validRules(stored.dangerRules);
173
+ if (stored.gateSettings !== void 0) result.gateSettings = validGate(stored.gateSettings);
174
+ if (stored.checkSettings !== void 0) result.checkSettings = validCheck(stored.checkSettings);
175
+ if (stored.narrowSettings !== void 0) result.narrowSettings = validNarrow(stored.narrowSettings);
176
+ if (stored.completeSettings !== void 0) result.completeSettings = validComplete(stored.completeSettings);
177
+ if (stored.features !== void 0) result.features = validFeatures(stored.features);
178
+ return result;
179
+ }
180
+ async function loadConfig(path = configPath()) {
181
+ return view(await readStored(path));
182
+ }
183
+ async function resolveConfig({ path = configPath(), env = process.env } = {}) {
184
+ const stored = await readStored(path);
185
+ const url = checkedUrl(safeText(stored.url) || safeText(env.DSH_DECISION_BASE_URL) || safeText(env.TYPESAFE_BASE_URL) || DEFAULT_URL);
186
+ const resolved = {
187
+ url,
188
+ apiKey: safeText(stored.apiKey) || safeText(env.DSH_DECISION_API_KEY) || safeText(env.TYPESAFE_API_KEY),
189
+ model: safeText(stored.model) || safeText(env.DSH_DECISION_MODEL) || DEFAULT_MODEL,
190
+ httpApprovedUrl: safeText(stored.httpApprovedUrl)
191
+ };
192
+ if (stored.dangerRules !== void 0) resolved.dangerRules = validRules(stored.dangerRules);
193
+ if (stored.gateSettings !== void 0) resolved.gateSettings = validGate(stored.gateSettings);
194
+ if (stored.checkSettings !== void 0) resolved.checkSettings = validCheck(stored.checkSettings);
195
+ if (stored.narrowSettings !== void 0) resolved.narrowSettings = validNarrow(stored.narrowSettings);
196
+ if (stored.completeSettings !== void 0) resolved.completeSettings = validComplete(stored.completeSettings);
197
+ if (stored.features !== void 0) resolved.features = validFeatures(stored.features);
198
+ return resolved;
199
+ }
200
+ const SAVE_FIELDS = ["url", "apiKey", "model", "confirmHttpUrl", "dangerRules", "gateSettings", "checkSettings", "narrowSettings", "completeSettings", "features"];
201
+ const TEXT_FIELDS = ["url", "apiKey", "model", "confirmHttpUrl"];
202
+ async function saveConfig(input, path = configPath()) {
203
+ if (!safeObject(input) || Object.keys(input).some((key) => !SAVE_FIELDS.includes(key))) {
204
+ throw new Error("Invalid configuration fields");
205
+ }
206
+ for (const key of TEXT_FIELDS) {
207
+ if (input[key] !== void 0 && typeof input[key] !== "string") throw new Error(`Invalid ${key}`);
208
+ }
209
+ const previous = await readStored(path);
210
+ const url = checkedUrl(safeText(input.url === void 0 ? previous.url : input.url));
211
+ const apiKey = safeText(input.apiKey === void 0 ? previous.apiKey : input.apiKey);
212
+ const model = safeText(input.model === void 0 ? previous.model : input.model);
213
+ if (model.length > 128 || /[\r\n]/.test(input.model ?? "") || /[\r\n]/.test(input.apiKey ?? "")) {
214
+ throw new Error("Invalid model or API key");
215
+ }
216
+ const dangerRules = input.dangerRules === void 0 ? previous.dangerRules : validRules(input.dangerRules);
217
+ const gateSettings = input.gateSettings === void 0 ? previous.gateSettings : validGate(input.gateSettings);
218
+ const checkSettings = input.checkSettings === void 0 ? previous.checkSettings : validCheck(input.checkSettings);
219
+ const narrowSettings = input.narrowSettings === void 0 ? previous.narrowSettings : validNarrow(input.narrowSettings);
220
+ const completeSettings = input.completeSettings === void 0 ? previous.completeSettings : validComplete(input.completeSettings);
221
+ const features = input.features === void 0 ? previous.features : validFeatures(input.features);
222
+ const confirmed = url.startsWith("http://") && input.confirmHttpUrl !== void 0 && checkedUrl(safeText(input.confirmHttpUrl)) === url;
223
+ if (url.startsWith("http://") && !confirmed && previous.httpApprovedUrl !== url) {
224
+ throw new Error("HTTP URL requires explicit confirmation");
225
+ }
226
+ const next = { url, apiKey, model, httpApprovedUrl: url.startsWith("http://") ? url : "" };
227
+ if (dangerRules !== void 0) next.dangerRules = dangerRules;
228
+ if (gateSettings !== void 0) next.gateSettings = gateSettings;
229
+ if (checkSettings !== void 0) next.checkSettings = checkSettings;
230
+ if (narrowSettings !== void 0) next.narrowSettings = narrowSettings;
231
+ if (completeSettings !== void 0) next.completeSettings = completeSettings;
232
+ if (features !== void 0) next.features = features;
233
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
234
+ const temporary = `${path}.${randomUUID()}.tmp`;
235
+ try {
236
+ await writeFile(temporary, `${JSON.stringify(next)}
237
+ `, { mode: 384, flag: "wx" });
238
+ await chmod(temporary, 384);
239
+ await rename(temporary, path);
240
+ } finally {
241
+ await rm(temporary, { force: true });
242
+ }
243
+ return view(next);
244
+ }
245
+ export {
246
+ DEFAULT_MODEL,
247
+ DEFAULT_URL,
248
+ FEATURE_KEYS,
249
+ configPath,
250
+ featureEnabled,
251
+ loadConfig,
252
+ resolveConfig,
253
+ saveConfig
254
+ };
@@ -0,0 +1,45 @@
1
+ import type { AgentLike, DangerRuleAdditions, DangerRuleSet, Evaluator, SessionsFace, ToolExec } from './types.js';
2
+ export declare const MIN_CONFIDENCE = 0.6;
3
+ export declare const DEFAULT_DANGEROUS_RULES: DangerRuleSet;
4
+ export declare function summarizeExecution(exec: ToolExec | undefined): {
5
+ tool: string;
6
+ command: string;
7
+ path: string;
8
+ inspectable: boolean;
9
+ };
10
+ export type RulesInput = DangerRuleSet | DangerRuleAdditions;
11
+ export declare function normalizeDangerRules(input?: DangerRuleAdditions): DangerRuleSet;
12
+ export declare function classifyDangerous(exec: ToolExec | undefined, rules?: RulesInput): {
13
+ dangerous: boolean;
14
+ reason: string;
15
+ };
16
+ type GateDecision = {
17
+ kind: 'deny';
18
+ reason: string;
19
+ modelSuggestion: string;
20
+ };
21
+ type RulesResolver = RulesInput | (() => Promise<RulesInput | undefined>);
22
+ type MinConfidenceResolver = number | (() => Promise<number | undefined>);
23
+ export type DangerGateOptions = {
24
+ evaluate?: Evaluator;
25
+ sessions?: SessionsFace;
26
+ rules?: RulesResolver;
27
+ minConfidence?: MinConfidenceResolver;
28
+ /**
29
+ * The turn's request text, used only for the bounded, masked excerpt attached
30
+ * to a decision row. Called lazily when a dangerous call is recorded, so the
31
+ * ordinary tool path never derives messages.
32
+ */
33
+ taskText?: (agent: AgentLike | undefined) => unknown;
34
+ };
35
+ export declare function createDangerGate({ evaluate, sessions, rules, minConfidence, taskText }?: DangerGateOptions): {
36
+ classify: (exec: ToolExec | undefined) => {
37
+ dangerous: boolean;
38
+ reason: string;
39
+ };
40
+ guardReason(exec: unknown): string | undefined;
41
+ observeResult(exec: ToolExec | undefined, result: unknown): void;
42
+ evaluate(exec: ToolExec): Promise<GateDecision | undefined>;
43
+ preExecute(exec: ToolExec, next: () => unknown): Promise<unknown>;
44
+ };
45
+ export {};
@@ -0,0 +1,196 @@
1
+ import { taskExcerpt } from "./excerpt.js";
2
+ import { errorReason, isObject, isProbability } from "./types.js";
3
+ const DEFAULT_COMMAND_PATTERNS = Object.freeze([
4
+ /\brm\b(?=[^\n]*(?:\s-r(?:f|fr)?\b|\s--recursive\b|\s--force\b))/i,
5
+ /\bgit\s+push\b[^\n]*(?:\s-f\b|\s--force(?:-with-lease)?\b)/i,
6
+ /\b(?:drop|truncate)\s+(?:table|database)\b/i,
7
+ /\bkubectl\s+delete\b/i,
8
+ /\b(?:prod|production)\b[^\n]*\b(?:write|deploy|config|secret)\b/i
9
+ ]);
10
+ const DEFAULT_PATH_PATTERNS = Object.freeze([/(?:^|[\\/])(?:etc|production|prod)[\\/]/i, /(?:^|[\\/])\.env(?:$|\.)/i]);
11
+ const COMMAND_KEYS = /* @__PURE__ */ new Set(["command", "cmd", "script", "query"]);
12
+ const PATH_KEYS = /* @__PURE__ */ new Set(["path", "file_path", "filePath", "target", "file", "filename", "directory"]);
13
+ const MAX_RULES = 64;
14
+ const MAX_PATTERN_LENGTH = 128;
15
+ const MAX_METADATA_LENGTH = 2048;
16
+ const MIN_CONFIDENCE = 0.6;
17
+ const DEFAULT_DANGEROUS_RULES = Object.freeze({
18
+ toolNames: Object.freeze(["bash", "pwsh", "shell", "exec", "terminal", "run_command", "write", "edit", "str_replace_editor", "file_write", "write_file"]),
19
+ commandPatterns: DEFAULT_COMMAND_PATTERNS,
20
+ pathPatterns: DEFAULT_PATH_PATTERNS
21
+ });
22
+ const redact = (value) => value.slice(0, MAX_METADATA_LENGTH).replace(/(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+/gi, "$1=[redacted]").replace(/(password|passwd|secret|token|api[_-]?key)\s+\S+/gi, "$1 [redacted]");
23
+ const literalPattern = (value) => new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
24
+ const boundedList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string" && Boolean(item.trim()) && item.length <= MAX_PATTERN_LENGTH).slice(0, MAX_RULES) : [];
25
+ function collectMetadata(value, depth = 0, commands = [], paths = []) {
26
+ if (depth > 4 || value === null || typeof value !== "object") return;
27
+ for (const [key, item] of Object.entries(value)) {
28
+ const normalized = key.toLowerCase();
29
+ if (typeof item === "string" && COMMAND_KEYS.has(normalized)) commands.push(redact(item));
30
+ else if (typeof item === "string" && PATH_KEYS.has(normalized)) paths.push(redact(item));
31
+ else if (item && typeof item === "object" && !["content", "body", "data", "metadata", "headers", "env", "value"].includes(normalized)) {
32
+ collectMetadata(item, depth + 1, commands, paths);
33
+ }
34
+ }
35
+ return { commands, paths };
36
+ }
37
+ function summarizeExecution(exec) {
38
+ const commands = [];
39
+ const paths = [];
40
+ const collected = collectMetadata(exec?.arguments, 0, commands, paths);
41
+ return {
42
+ tool: typeof exec?.name === "string" ? exec.name.slice(0, 128) : "",
43
+ command: commands.join("\n").slice(0, MAX_METADATA_LENGTH),
44
+ path: paths.join("\n").slice(0, MAX_METADATA_LENGTH),
45
+ inspectable: Boolean(collected)
46
+ };
47
+ }
48
+ function normalizeDangerRules(input = {}) {
49
+ const addTools = boundedList(input.addToolNames);
50
+ const removeTools = new Set(boundedList(input.removeToolNames));
51
+ const tools = [.../* @__PURE__ */ new Set([...DEFAULT_DANGEROUS_RULES.toolNames, ...addTools])].filter((tool) => !removeTools.has(tool));
52
+ const commands = [...DEFAULT_COMMAND_PATTERNS, ...boundedList(input.addCommandPatterns).map(literalPattern)];
53
+ const paths = [...DEFAULT_PATH_PATTERNS, ...boundedList(input.addPathPatterns).map(literalPattern)];
54
+ return { toolNames: tools, commandPatterns: commands, pathPatterns: paths };
55
+ }
56
+ const isRuleSet = (rules) => Array.isArray(rules.toolNames);
57
+ function classifyDangerous(exec, rules = DEFAULT_DANGEROUS_RULES) {
58
+ const normalized = isRuleSet(rules) ? rules : normalizeDangerRules(rules);
59
+ const summary = summarizeExecution(exec);
60
+ if (!normalized.toolNames.includes(summary.tool)) return { dangerous: false, reason: "Tool is outside the dangerous-call rules" };
61
+ if (!summary.inspectable) return { dangerous: true, reason: "Dangerous call arguments could not be inspected" };
62
+ const byCommand = normalized.commandPatterns.some((pattern) => pattern.test(summary.command));
63
+ const byPath = normalized.pathPatterns.some((pattern) => pattern.test(summary.path));
64
+ return { dangerous: byCommand || byPath || !summary.command && !summary.path, reason: "Dangerous tool call requires a host decision" };
65
+ }
66
+ function suggestionQuestion(exec) {
67
+ return {
68
+ state: summarizeExecution(exec),
69
+ questions: { verdict: {
70
+ type: "choice",
71
+ instructions: "Classify this dangerous tool call. Never grant a permission the host has not already granted.",
72
+ criteria: { allow: "No additional intervention is recommended", ask: "Ask the host user for approval", deny: "Reject this dangerous call" }
73
+ } }
74
+ };
75
+ }
76
+ function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS_RULES, minConfidence = MIN_CONFIDENCE, taskText } = {}) {
77
+ if (typeof evaluate !== "function") throw new Error("Danger gate requires an evaluator");
78
+ const resolveRules = async () => {
79
+ if (typeof rules !== "function") return rules;
80
+ try {
81
+ return await rules() ?? DEFAULT_DANGEROUS_RULES;
82
+ } catch {
83
+ return DEFAULT_DANGEROUS_RULES;
84
+ }
85
+ };
86
+ const resolveMinConfidence = async () => {
87
+ if (typeof minConfidence !== "function") return isProbability(minConfidence) ? minConfidence : MIN_CONFIDENCE;
88
+ try {
89
+ const value = await minConfidence();
90
+ return isProbability(value) ? value : MIN_CONFIDENCE;
91
+ } catch {
92
+ return MIN_CONFIDENCE;
93
+ }
94
+ };
95
+ const deniedCalls = /* @__PURE__ */ new WeakSet();
96
+ const evaluatedCalls = /* @__PURE__ */ new WeakSet();
97
+ const record = (exec, outcome, detail = {}) => {
98
+ const id = exec?.agent?.session?.id;
99
+ if (!sessions || typeof id !== "string") return;
100
+ try {
101
+ sessions.record?.(id, "gate", outcome);
102
+ } catch {
103
+ }
104
+ if (typeof sessions.log !== "function") return;
105
+ const summary = summarizeExecution(exec);
106
+ const args = {};
107
+ if (summary.command) args.command = summary.command;
108
+ if (summary.path) args.path = summary.path;
109
+ let task;
110
+ if (typeof taskText === "function") {
111
+ try {
112
+ task = taskExcerpt(taskText(exec?.agent));
113
+ } catch {
114
+ task = void 0;
115
+ }
116
+ }
117
+ if (task) args.task = task;
118
+ try {
119
+ sessions.log(id, { kind: "gate", outcome, tool: exec?.name, ...args, ...detail });
120
+ } catch {
121
+ }
122
+ };
123
+ const passThrough = (exec, outcome, detail = {}) => {
124
+ record(exec, outcome, { action: "pass", ...detail });
125
+ if (exec && typeof exec === "object") evaluatedCalls.add(exec);
126
+ return void 0;
127
+ };
128
+ const gate = {
129
+ classify: (exec) => classifyDangerous(exec, typeof rules === "function" ? DEFAULT_DANGEROUS_RULES : rules),
130
+ guardReason(exec) {
131
+ if (!isObject(exec) || !deniedCalls.has(exec)) return void 0;
132
+ deniedCalls.delete(exec);
133
+ return "Dangerous call rejected by decision policy";
134
+ },
135
+ observeResult(exec, result) {
136
+ if (!exec || typeof exec !== "object" || !evaluatedCalls.has(exec) || !sessions || typeof sessions.recordActual !== "function") return;
137
+ const id = exec.agent?.session?.id;
138
+ if (typeof id !== "string") return;
139
+ const isError = isObject(result) && result.isError === true;
140
+ try {
141
+ sessions.recordActual(id, isError ? "deny" : "allow");
142
+ } catch {
143
+ }
144
+ evaluatedCalls.delete(exec);
145
+ },
146
+ async evaluate(exec) {
147
+ let result;
148
+ try {
149
+ result = await evaluate(suggestionQuestion(exec), { signal: exec.signal });
150
+ } catch (error) {
151
+ return passThrough(exec, "error", { reason: errorReason(error) });
152
+ }
153
+ const answer = result?.answers?.verdict;
154
+ const choice = typeof answer?.choice === "string" ? answer.choice : void 0;
155
+ const confidence = typeof answer?.confidence === "number" ? answer.confidence : void 0;
156
+ if (choice === void 0 || !["allow", "ask", "deny"].includes(choice)) return passThrough(exec, "error", { reason: "invalid-response" });
157
+ const minConfidenceNow = await resolveMinConfidence();
158
+ const confident = confidence !== void 0 && Number.isFinite(confidence) && confidence >= minConfidenceNow;
159
+ if (choice === "deny" && confident) {
160
+ record(exec, "deny", { suggestion: "deny", action: "deny" });
161
+ if (exec && typeof exec === "object") deniedCalls.add(exec);
162
+ return { kind: "deny", reason: "Dangerous call rejected by decision policy", modelSuggestion: choice };
163
+ }
164
+ const detail = { suggestion: choice };
165
+ if (!confident) {
166
+ detail.reason = "low-confidence";
167
+ if (confidence !== void 0 && Number.isFinite(confidence)) detail.confidence = confidence;
168
+ }
169
+ return passThrough(exec, "allow", detail);
170
+ },
171
+ async preExecute(exec, next) {
172
+ const classification = classifyDangerous(exec, await resolveRules());
173
+ if (!classification.dangerous) return next();
174
+ const id = exec?.agent?.session?.id;
175
+ if (sessions && typeof id === "string") {
176
+ try {
177
+ if (sessions.snapshot(id).enabled === false) return next();
178
+ } catch {
179
+ passThrough(exec, "error", { reason: "unreachable" });
180
+ return next();
181
+ }
182
+ }
183
+ const decision = await gate.evaluate(exec);
184
+ return decision ?? next();
185
+ }
186
+ };
187
+ return gate;
188
+ }
189
+ export {
190
+ DEFAULT_DANGEROUS_RULES,
191
+ MIN_CONFIDENCE,
192
+ classifyDangerous,
193
+ createDangerGate,
194
+ normalizeDangerRules,
195
+ summarizeExecution
196
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The one bounded, redacted "what was this turn about" excerpt every decision
3
+ * row may carry into the local decision log.
4
+ *
5
+ * It is a human-review hint, never a dump of structured state: injected host
6
+ * context (memory blocks, system reminders) is stripped first, then common
7
+ * credential forms are masked, and only then is the text collapsed and cut.
8
+ * The same rules must hold for every decision point — the panel and the JSONL
9
+ * sink store what this returns, so a weaker copy elsewhere would leak.
10
+ *
11
+ * Masking deliberately errs toward over-redaction: a false positive only costs
12
+ * the reader some context, while a miss writes a secret to disk for 30 days.
13
+ * The rules cover labeled key/value pairs (`password`, `token`, Chinese
14
+ * 密码/令牌 …), HTTP credential headers, URL userinfo, CLI flags, copula forms
15
+ * (`token is …`), and well-known token shapes that appear without any label.
16
+ */
17
+ export declare const TASK_EXCERPT_LIMIT = 512;
18
+ /**
19
+ * Mask the credential forms that show up in a task excerpt. Exported so the
20
+ * storage layer can apply the same rules as a second boundary (it is cheap and
21
+ * idempotent), keeping a future caller that forgets `taskExcerpt` from writing
22
+ * a secret to the log.
23
+ */
24
+ export declare function redactCredentials(input: string): string;
25
+ export declare function taskExcerpt(state: unknown, limit?: number): string | undefined;
@@ -0,0 +1,62 @@
1
+ const MAX_INPUT = 8 * 1024;
2
+ const TASK_EXCERPT_LIMIT = 512;
3
+ const TOKEN_KEYS = String.raw`token|api[ _-]*key`;
4
+ const PHRASE_KEYS = String.raw`password|passwd|passphrase|secret`;
5
+ const CN_KEYS = String.raw`密码|口令|密码短语|密钥|令牌|凭证|私钥`;
6
+ const COMBINED_KEYS = String.raw`${PHRASE_KEYS}|${TOKEN_KEYS}`;
7
+ const SEPARATOR = String.raw`[:=:=]`;
8
+ const VALUE = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;},;、]+)`;
9
+ const CLAUSE = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\r\n,;,;、]*)`;
10
+ const COPULA = String.raw`is|are|was|were|是|为`;
11
+ const VALUE_LIKE = String.raw`(?=\S*(?:\d|[A-Za-z0-9_-]{12,}))`;
12
+ const TOKEN_SHAPES = [
13
+ [/\b(sk|pk|rk)-[A-Za-z0-9_-]{8,}/gi, "$1-[redacted]"],
14
+ [/\b(gh[pousr]_)[A-Za-z0-9]{20,}/g, "$1[redacted]"],
15
+ [/\b(xox[baprs]-)[A-Za-z0-9-]{10,}/g, "$1[redacted]"],
16
+ [/\b(AKIA)[0-9A-Z]{16}\b/g, "$1[redacted]"],
17
+ [/\b(glpat-)[A-Za-z0-9_-]{10,}/g, "$1[redacted]"],
18
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*/g, "[redacted]"]
19
+ ];
20
+ const RULE = {
21
+ // Template placeholders (`{{SECRET_...}}`) hide a value inside braces the
22
+ // value rules deliberately do not consume, so mask the whole placeholder.
23
+ template: /\{\{[^{}\r\n]*\}\}/g,
24
+ bearer: /\bBearer\s+[^\s,;]+/gi,
25
+ urlUserinfo: /([a-z][a-z0-9+.-]*:\/\/)[^\s/]*@/gi,
26
+ header: new RegExp(String.raw`(\b(?:authorization|cookie|set-cookie)\b["']?\s*${SEPARATOR}\s*)[^\r\n]*`, "gi"),
27
+ phrase: new RegExp(String.raw`(\b(?:${PHRASE_KEYS})\b["']?\s*${SEPARATOR}\s*)${CLAUSE}`, "gi"),
28
+ cnKey: new RegExp(String.raw`((?:${CN_KEYS})\s*[::=]\s*)${VALUE}`, "g"),
29
+ token: new RegExp(String.raw`(\b(?:${TOKEN_KEYS})\b["']?\s*${SEPARATOR}\s*)${VALUE}`, "gi"),
30
+ asciiCopula: new RegExp(String.raw`(\b(?:${COMBINED_KEYS})\b\s*(?:${COPULA})\s*)${VALUE_LIKE}${VALUE}`, "gi"),
31
+ cnCopula: new RegExp(String.raw`((?:${CN_KEYS})\s*(?:是|为)\s*)${VALUE_LIKE}${VALUE}`, "g"),
32
+ flag: new RegExp(String.raw`(^|\s)(--(?:${COMBINED_KEYS})(?:\s+|=))${VALUE}`, "gi"),
33
+ // Legacy space form (`token abc`), but never swallow a copula: the rule above
34
+ // masks the value that follows it.
35
+ loose: new RegExp(String.raw`(\b(?:${COMBINED_KEYS})\b\s+)(?!(?:${COPULA})(?:\s|$)|${SEPARATOR})${VALUE}`, "gi")
36
+ };
37
+ function redactCredentials(input) {
38
+ let text = input.replace(RULE.template, "[redacted]");
39
+ text = text.replace(RULE.bearer, "Bearer [redacted]");
40
+ text = text.replace(RULE.urlUserinfo, "$1[redacted]@");
41
+ text = text.replace(RULE.header, "$1[redacted]");
42
+ text = text.replace(RULE.phrase, "$1[redacted]");
43
+ text = text.replace(RULE.cnKey, "$1[redacted]");
44
+ text = text.replace(RULE.token, "$1[redacted]");
45
+ text = text.replace(RULE.asciiCopula, "$1[redacted]");
46
+ text = text.replace(RULE.cnCopula, "$1[redacted]");
47
+ text = text.replace(RULE.flag, "$1$2[redacted]");
48
+ text = text.replace(RULE.loose, "$1[redacted]");
49
+ for (const [pattern, replacement] of TOKEN_SHAPES) text = text.replace(pattern, replacement);
50
+ return text;
51
+ }
52
+ function taskExcerpt(state, limit = TASK_EXCERPT_LIMIT) {
53
+ if (typeof state !== "string") return void 0;
54
+ const stripped = state.slice(0, MAX_INPUT).replace(/<(openviking-context|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<(openviking-context|system-reminder)\b[\s\S]*$/i, " ");
55
+ const text = redactCredentials(stripped).replace(/\s+/g, " ").trim();
56
+ return text ? text.slice(0, limit) : void 0;
57
+ }
58
+ export {
59
+ TASK_EXCERPT_LIMIT,
60
+ redactCredentials,
61
+ taskExcerpt
62
+ };
@@ -0,0 +1,6 @@
1
+ import type { HostContext } from './types.js';
2
+ export declare const name = "dsh-decision-layer";
3
+ export declare const inject: string[];
4
+ export declare function apply(ctx: HostContext, options?: {
5
+ configPath?: string;
6
+ }): Promise<void>;