@goodandready/dsh-approval-gate 0.1.16 → 0.1.18

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.
@@ -0,0 +1,392 @@
1
+ // lib/classifier.js
2
+ // LLM Semantic Classifier resilience, URL normalization, fault tolerance,
3
+ // secret sanitization, context truncation, and timeout policies (#127, #129, #118, #115, #117).
4
+
5
+ import { redactCredentials, redactUrl } from "./redact.js";
6
+ import { CircuitBreaker } from "./breaker.js";
7
+
8
+ // Additional regex patterns for sensitive tokens & API keys (#115)
9
+ const OPENAI_KEY_PATTERN = /\bsk-[a-zA-Z0-9_\-]{20,}\b/g;
10
+ const ANTHROPIC_KEY_PATTERN = /\bsk-ant-[a-zA-Z0-9_\-]{20,}\b/g;
11
+ const NPM_TOKEN_PATTERN = /\bnpm_[a-zA-Z0-9]{36}\b/g;
12
+ const BEARER_HEADER_PATTERN = /\bBearer\s+[a-zA-Z0-9_\-\.]{15,}\b/gi;
13
+ const INLINE_CREDENTIAL_PATTERN = /(?:-p|--password|--passwd|--token|--api[-_]?key)\s*(?:=|\s+)[^\s'"]+/gi;
14
+ const SENSITIVE_KEY_NAMES = /^(?:token|key|api[-_]?key|secret|password|passwd|auth|authorization|private[-_]?key|client[-_]?secret)$/i;
15
+
16
+ /**
17
+ * Normalizes user-provided LLM endpoint URLs (#127).
18
+ * Handles bare host/port, duplicate slashes, trailing slashes, and appends /chat/completions when missing.
19
+ *
20
+ * @param {string} inputUrl
21
+ * @returns {string}
22
+ */
23
+ export function normalizeChatCompletionsUrl(inputUrl) {
24
+ if (typeof inputUrl !== "string" || inputUrl.trim() === "") {
25
+ throw new TypeError("Classifier endpoint URL must be a non-empty string");
26
+ }
27
+
28
+ let raw = inputUrl.trim();
29
+ if (!/^https?:\/\//i.test(raw)) {
30
+ raw = "http://" + raw;
31
+ }
32
+
33
+ // Remove duplicate slashes after protocol
34
+ raw = raw.replace(/([^:]\/)\/+/g, "$1");
35
+
36
+ const parsed = new URL(raw);
37
+ let pathname = parsed.pathname.replace(/\/+$/, ""); // remove trailing slashes
38
+ pathname = pathname.replace(/\/{2,}/g, "/");
39
+
40
+ if (pathname.endsWith("/chat/completions")) {
41
+ // Already has standard completions endpoint
42
+ parsed.pathname = pathname;
43
+ } else if (pathname.endsWith("/v1")) {
44
+ parsed.pathname = pathname + "/chat/completions";
45
+ } else if (pathname === "" || pathname === "/") {
46
+ parsed.pathname = "/chat/completions";
47
+ } else {
48
+ parsed.pathname = pathname + "/chat/completions";
49
+ }
50
+
51
+ // Ensure clean pathname without trailing slash
52
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
53
+ return parsed.toString();
54
+ }
55
+
56
+ /**
57
+ * Sanitizes arguments and command strings to prevent credential leaks to external LLM APIs (#115).
58
+ * Masks tokens, passwords, private keys, auth headers, and sensitive object keys recursively.
59
+ *
60
+ * @param {any} input
61
+ * @returns {any}
62
+ */
63
+ export function sanitizeClassifierInput(input) {
64
+ if (typeof input === "string") {
65
+ let sanitized = redactCredentials(input);
66
+ sanitized = redactUrl(sanitized);
67
+ sanitized = sanitized.replace(OPENAI_KEY_PATTERN, "***");
68
+ sanitized = sanitized.replace(ANTHROPIC_KEY_PATTERN, "***");
69
+ sanitized = sanitized.replace(NPM_TOKEN_PATTERN, "***");
70
+ sanitized = sanitized.replace(BEARER_HEADER_PATTERN, "Bearer ***");
71
+ sanitized = sanitized.replace(INLINE_CREDENTIAL_PATTERN, "***");
72
+ return sanitized;
73
+ }
74
+
75
+ if (Array.isArray(input)) {
76
+ return input.map((item) => sanitizeClassifierInput(item));
77
+ }
78
+
79
+ if (input !== null && typeof input === "object") {
80
+ const sanitizedObj = {};
81
+ for (const [key, val] of Object.entries(input)) {
82
+ if (SENSITIVE_KEY_NAMES.test(key)) {
83
+ sanitizedObj[key] = "***";
84
+ } else {
85
+ sanitizedObj[key] = sanitizeClassifierInput(val);
86
+ }
87
+ }
88
+ return sanitizedObj;
89
+ }
90
+
91
+ return input;
92
+ }
93
+
94
+ /**
95
+ * Truncates classifier payload context to minimize token consumption (#117).
96
+ *
97
+ * @param {any} input - String or object payload
98
+ * @param {number} [maxChars=1000] - Character cap
99
+ * @returns {string} Truncated string representation with metadata marker
100
+ */
101
+ export function truncateClassifierContext(input, maxChars = 1000) {
102
+ const cap = typeof maxChars === "number" && maxChars > 0 ? maxChars : 1000;
103
+
104
+ if (typeof input === "string") {
105
+ if (input.length <= cap) {
106
+ return input;
107
+ }
108
+ return `${input.slice(0, cap)}\n[...truncated, total: ${input.length} chars]`;
109
+ }
110
+
111
+ let jsonStr;
112
+ try {
113
+ jsonStr = JSON.stringify(input, null, 2);
114
+ } catch {
115
+ jsonStr = String(input);
116
+ }
117
+
118
+ if (jsonStr.length <= cap) {
119
+ return jsonStr;
120
+ }
121
+
122
+ // Attempt smart field truncation if object has large fields
123
+ if (input !== null && typeof input === "object" && !Array.isArray(input)) {
124
+ const copy = { ...input };
125
+ const fieldBudget = Math.max(100, Math.floor(cap / 2));
126
+ for (const key of ["command", "content", "diff", "text", "script", "patch", "data"]) {
127
+ if (typeof copy[key] === "string" && copy[key].length > fieldBudget) {
128
+ copy[key] = `${copy[key].slice(0, fieldBudget)}\n[...truncated field, total: ${copy[key].length} chars]`;
129
+ }
130
+ }
131
+ try {
132
+ const reducedJson = JSON.stringify(copy, null, 2);
133
+ if (reducedJson.length <= cap) {
134
+ return reducedJson;
135
+ }
136
+ } catch {
137
+ // fallback to hard truncation below
138
+ }
139
+ }
140
+
141
+ return `${jsonStr.slice(0, cap)}\n[...truncated, total: ${jsonStr.length} chars]`;
142
+ }
143
+
144
+ /**
145
+ * Default error inspector for retrying LLM HTTP requests (#129).
146
+ * Retries on network faults, timeouts, 429 rate limits, and 5xx server errors.
147
+ * Never retries on 400, 401, 403, 404, 422 client errors.
148
+ *
149
+ * @param {any} err
150
+ * @returns {boolean}
151
+ */
152
+ export function shouldRetryLlmError(err) {
153
+ if (!err) return false;
154
+ if (CircuitBreaker.isFaultOrTimeout(err)) return true;
155
+
156
+ const status = err.status || (err.response && err.response.status);
157
+ if (typeof status === "number") {
158
+ if (status === 429) return true;
159
+ if (status >= 500) return true;
160
+ if (status >= 400 && status < 500) return false;
161
+ }
162
+
163
+ const code = err.code;
164
+ if (code && ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "UND_ERR_CONNECT_TIMEOUT"].includes(code)) {
165
+ return true;
166
+ }
167
+
168
+ if (err.name === "AbortError" || err.isTimeout) {
169
+ return true;
170
+ }
171
+
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Executes an async LLM operation with automatic retry and exponential backoff (#129).
177
+ *
178
+ * @template T
179
+ * @param {(attempt: number) => Promise<T>} fn
180
+ * @param {{ maxRetries?: number, retryDelayMs?: number, backoffFactor?: number, retryOn?: (err: any) => boolean }} [options]
181
+ * @returns {Promise<T>}
182
+ */
183
+ export async function withLlmRetry(fn, options = {}) {
184
+ const maxRetries = typeof options.maxRetries === "number" ? options.maxRetries : 2;
185
+ const initialDelay = typeof options.retryDelayMs === "number" ? options.retryDelayMs : 200;
186
+ const backoffFactor = typeof options.backoffFactor === "number" ? options.backoffFactor : 2;
187
+ const checkRetry = options.retryOn || shouldRetryLlmError;
188
+
189
+ let attempt = 0;
190
+ let delay = initialDelay;
191
+
192
+ while (true) {
193
+ try {
194
+ return await fn(attempt);
195
+ } catch (err) {
196
+ attempt++;
197
+ if (attempt > maxRetries || !checkRetry(err)) {
198
+ if (err && typeof err === "object") {
199
+ err.attempts = attempt;
200
+ }
201
+ throw err;
202
+ }
203
+ await new Promise((resolve) => setTimeout(resolve, delay));
204
+ delay *= backoffFactor;
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Checks if an execution represents a low-risk, read-only operation (#118).
211
+ *
212
+ * @param {{ name?: string, arguments?: any }} execution
213
+ * @returns {boolean}
214
+ */
215
+ export function isLowRiskExecution(execution) {
216
+ if (!execution) return false;
217
+ const toolName = execution.name || "";
218
+ const command = execution.arguments && execution.arguments.command;
219
+
220
+ // File viewing / inspection tools
221
+ if (["read_file", "view_file", "list_dir", "glob", "search_files", "grep"].includes(toolName)) {
222
+ return true;
223
+ }
224
+
225
+ if (typeof command === "string") {
226
+ const trimmed = command.trim();
227
+ if (!trimmed) return true;
228
+
229
+ // Reject any dangerous or mutating commands
230
+ if (/\b(?:rm|mv|cp|chmod|chown|sudo|dd|mkfs|curl|wget|nc|bash|sh|zsh|kill|pkill)\b/i.test(trimmed)) {
231
+ return false;
232
+ }
233
+ // Reject shell redirections or pipes to execution
234
+ if (/[><|;&]/.test(trimmed)) {
235
+ return false;
236
+ }
237
+ // Safe commands
238
+ if (/^(?:git\s+(?:status|log|diff|branch|show)|ls|pwd|cat|echo|head|tail|wc|whoami|uname)\b/.test(trimmed)) {
239
+ return true;
240
+ }
241
+ }
242
+
243
+ return false;
244
+ }
245
+
246
+ /**
247
+ * Standalone resilient LLM Classifier client (#127, #129, #118, #115, #117).
248
+ */
249
+ export class LlmClassifier {
250
+ /**
251
+ * @param {{
252
+ * endpoint?: string,
253
+ * apiKey?: string,
254
+ * model?: string,
255
+ * timeoutMs?: number,
256
+ * timeoutPolicy?: "strict" | "permissive",
257
+ * maxRetries?: number,
258
+ * maxContextChars?: number,
259
+ * fetch?: typeof globalThis.fetch
260
+ * }} [options]
261
+ */
262
+ constructor(options = {}) {
263
+ this.endpoint = options.endpoint ? normalizeChatCompletionsUrl(options.endpoint) : null;
264
+ this.apiKey = options.apiKey || "";
265
+ this.model = options.model || "gpt-4o-mini";
266
+ this.timeoutMs = typeof options.timeoutMs === "number" ? options.timeoutMs : 3000;
267
+ this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
268
+ this.maxRetries = typeof options.maxRetries === "number" ? options.maxRetries : 2;
269
+ this.maxContextChars = typeof options.maxContextChars === "number" ? options.maxContextChars : 1000;
270
+ this.fetch = options.fetch || globalThis.fetch;
271
+ }
272
+
273
+ /**
274
+ * Builds prompt messages with sanitized credentials and bounded context size (#115, #117).
275
+ *
276
+ * @param {any} execution
277
+ * @param {any} [context]
278
+ * @returns {Array<{ role: string, content: string }>}
279
+ */
280
+ formatMessages(execution, context) {
281
+ const sanitized = sanitizeClassifierInput(execution);
282
+ const truncatedPayload = truncateClassifierContext(sanitized, this.maxContextChars);
283
+
284
+ return [
285
+ {
286
+ role: "system",
287
+ content:
288
+ "You are a security approval gate classifier. Analyze the provided tool execution request. " +
289
+ "Respond strictly in valid JSON format: {\"verdict\": \"allow\"|\"deny\"|\"ask\", \"reason\": \"<short explanation>\"}.",
290
+ },
291
+ {
292
+ role: "user",
293
+ content: `Evaluate execution:\n${truncatedPayload}`,
294
+ },
295
+ ];
296
+ }
297
+
298
+ /**
299
+ * Classifies an execution request using the configured LLM endpoint (#118, #127, #129, #115, #117).
300
+ *
301
+ * @param {any} execution
302
+ * @param {any} [context]
303
+ * @returns {Promise<{ verdict: "allow" | "deny" | "ask", reason: string, timeout?: boolean, error?: any }>}
304
+ */
305
+ async classify(execution, context = {}) {
306
+ if (!this.endpoint || typeof this.fetch !== "function") {
307
+ return { verdict: "ask", reason: "classifier-not-configured" };
308
+ }
309
+
310
+ const messages = this.formatMessages(execution, context);
311
+ const body = JSON.stringify({
312
+ model: this.model,
313
+ messages,
314
+ temperature: 0.1,
315
+ response_format: { type: "json_object" },
316
+ });
317
+
318
+ try {
319
+ const response = await withLlmRetry(
320
+ async () => {
321
+ const controller = new AbortController();
322
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
323
+ try {
324
+ const res = await this.fetch(this.endpoint, {
325
+ method: "POST",
326
+ headers: {
327
+ "Content-Type": "application/json",
328
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
329
+ },
330
+ body,
331
+ signal: controller.signal,
332
+ });
333
+
334
+ if (!res.ok) {
335
+ const err = new Error(`LLM endpoint HTTP ${res.status}: ${res.statusText}`);
336
+ err.status = res.status;
337
+ throw err;
338
+ }
339
+
340
+ return await res.json();
341
+ } catch (err) {
342
+ if (err.name === "AbortError") {
343
+ const timeoutErr = new Error(`LLM classifier request timed out after ${this.timeoutMs}ms`);
344
+ timeoutErr.isTimeout = true;
345
+ throw timeoutErr;
346
+ }
347
+ throw err;
348
+ } finally {
349
+ clearTimeout(timer);
350
+ }
351
+ },
352
+ {
353
+ maxRetries: this.maxRetries,
354
+ retryOn: shouldRetryLlmError,
355
+ }
356
+ );
357
+
358
+ const content = response?.choices?.[0]?.message?.content;
359
+ if (!content) {
360
+ return { verdict: "ask", reason: "classifier-empty-response" };
361
+ }
362
+
363
+ let parsed;
364
+ try {
365
+ parsed = JSON.parse(content);
366
+ } catch {
367
+ return { verdict: "ask", reason: "classifier-invalid-json" };
368
+ }
369
+
370
+ const verdict = ["allow", "deny", "ask"].includes(parsed.verdict) ? parsed.verdict : "ask";
371
+ return {
372
+ verdict,
373
+ reason: parsed.reason || `classifier-${verdict}`,
374
+ };
375
+ } catch (err) {
376
+ const isTimeout = CircuitBreaker.isFaultOrTimeout(err) || Boolean(err?.isTimeout);
377
+ if (isTimeout && this.timeoutPolicy === "permissive") {
378
+ const isLow = isLowRiskExecution(execution);
379
+ if (isLow) {
380
+ return { verdict: "allow", reason: "classifier-timeout-fallback-permissive", timeout: true };
381
+ }
382
+ return { verdict: "ask", reason: "classifier-timeout-fallback-strict", timeout: true };
383
+ }
384
+
385
+ if (isTimeout) {
386
+ return { verdict: "ask", reason: "classifier-timeout-strict", timeout: true, error: err };
387
+ }
388
+
389
+ return { verdict: "ask", reason: "classifier-error", error: err };
390
+ }
391
+ }
392
+ }
@@ -0,0 +1,147 @@
1
+ // lib/contract-guard.js
2
+ // Enforces task contract boundaries and blocks out-of-scope system mutations (#87).
3
+
4
+ const SYSTEM_PACKAGE_MANAGERS = new Set([
5
+ "apt",
6
+ "apt-get",
7
+ "dpkg",
8
+ "yum",
9
+ "dnf",
10
+ "pacman",
11
+ "zypper",
12
+ "brew",
13
+ "snap",
14
+ "flatpak",
15
+ "apk",
16
+ ]);
17
+
18
+ const SYSTEM_SERVICE_COMMANDS = new Set([
19
+ "systemctl",
20
+ "service",
21
+ "init",
22
+ "reboot",
23
+ "shutdown",
24
+ "poweroff",
25
+ ]);
26
+
27
+ const SYSTEM_NETWORKING_COMMANDS = new Set([
28
+ "iptables",
29
+ "ufw",
30
+ "firewalld",
31
+ "ifconfig",
32
+ "netplan",
33
+ ]);
34
+
35
+ const SYSTEM_CONFIG_TARGETS = [
36
+ "/etc/passwd",
37
+ "/etc/shadow",
38
+ "/etc/sudoers",
39
+ "/etc/fstab",
40
+ "/etc/hosts",
41
+ "/etc/pam.d",
42
+ "/etc/profile",
43
+ "/etc/bash.bashrc",
44
+ ];
45
+
46
+ /**
47
+ * Validates whether a command or tool execution stays within task contract boundaries (#87).
48
+ */
49
+ export class ContractGuard {
50
+ /**
51
+ * @param {{
52
+ * allowSystemMutations?: boolean,
53
+ * allowedTools?: string[],
54
+ * allowedCommands?: string[],
55
+ * forbiddenCommands?: string[]
56
+ * }} [contract]
57
+ */
58
+ constructor(contract = {}) {
59
+ this.allowSystemMutations = contract.allowSystemMutations === true;
60
+ this.allowedTools = Array.isArray(contract.allowedTools) ? new Set(contract.allowedTools) : null;
61
+ this.allowedCommands = Array.isArray(contract.allowedCommands) ? new Set(contract.allowedCommands) : null;
62
+ this.forbiddenCommands = Array.isArray(contract.forbiddenCommands) ? new Set(contract.forbiddenCommands) : null;
63
+ }
64
+
65
+ /**
66
+ * Checks if an execution violates the task contract boundaries.
67
+ *
68
+ * @param {string} toolName
69
+ * @param {string} [command]
70
+ * @param {any} [execution]
71
+ * @returns {{ allowed: boolean, reason?: string, detail?: string, snippet?: string }}
72
+ */
73
+ check(toolName, command, execution) {
74
+ // 1. Tool-level restriction
75
+ if (this.allowedTools && toolName && !this.allowedTools.has(toolName)) {
76
+ return {
77
+ allowed: false,
78
+ reason: "contract-violation",
79
+ detail: `Tool '${toolName}' is not permitted by task contract`,
80
+ snippet: toolName,
81
+ };
82
+ }
83
+
84
+ if (!command || typeof command !== "string") {
85
+ return { allowed: true };
86
+ }
87
+
88
+ const trimmed = command.trim();
89
+ if (!trimmed) return { allowed: true };
90
+
91
+ const firstWord = trimmed.split(/\s+/)[0].replace(/^sudo\s+/, "");
92
+
93
+ // 2. Explicit forbidden command list
94
+ if (this.forbiddenCommands && this.forbiddenCommands.has(firstWord)) {
95
+ return {
96
+ allowed: false,
97
+ reason: "contract-violation",
98
+ detail: `Command '${firstWord}' is explicitly forbidden by task contract`,
99
+ snippet: firstWord,
100
+ };
101
+ }
102
+
103
+ // 3. System mutations guard when allowSystemMutations is false
104
+ if (!this.allowSystemMutations) {
105
+ if (SYSTEM_PACKAGE_MANAGERS.has(firstWord)) {
106
+ return {
107
+ allowed: false,
108
+ reason: "contract-violation",
109
+ detail: `Out-of-scope system package manager invocation: ${firstWord}`,
110
+ snippet: trimmed,
111
+ };
112
+ }
113
+
114
+ if (SYSTEM_SERVICE_COMMANDS.has(firstWord)) {
115
+ return {
116
+ allowed: false,
117
+ reason: "contract-violation",
118
+ detail: `Out-of-scope system service or reboot control: ${firstWord}`,
119
+ snippet: trimmed,
120
+ };
121
+ }
122
+
123
+ if (SYSTEM_NETWORKING_COMMANDS.has(firstWord)) {
124
+ return {
125
+ allowed: false,
126
+ reason: "contract-violation",
127
+ detail: `Out-of-scope network configuration command: ${firstWord}`,
128
+ snippet: trimmed,
129
+ };
130
+ }
131
+
132
+ // Check system configuration mutation redirects
133
+ for (const target of SYSTEM_CONFIG_TARGETS) {
134
+ if (trimmed.includes(target) && /[><]/.test(trimmed)) {
135
+ return {
136
+ allowed: false,
137
+ reason: "contract-violation",
138
+ detail: `Out-of-scope system configuration file write: ${target}`,
139
+ snippet: trimmed,
140
+ };
141
+ }
142
+ }
143
+ }
144
+
145
+ return { allowed: true };
146
+ }
147
+ }