@goodandready/dsh-approval-gate 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/lib/classifier.js +150 -20
- package/lib/engine.js +62 -17
- package/lib/index.js +21 -21
- package/lib/latency.js +137 -0
- package/lib/messages.js +14 -0
- package/lib/risk.js +224 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.19
|
|
4
|
+
|
|
5
|
+
- Security: Структурированная категоризация рисков операций по 5 стандартизированным бакетам (`READ_ONLY`, `WORKSPACE`, `EGRESS`, `SYSTEM`, `SECRETS`) в `lib/risk.js` с приоритетной оценкой чувствительности путей, команд и сетевых направлений (Refs: #58).
|
|
6
|
+
- Security: Многомерное тегирование рисков (`risk_tags`: `read-only`, `file-mutation`, `network`, `git`, `credentials`, `package-manager`, `system-mutation`, `privilege-escalation`, `destructive`) для детального анализа и обогащения решений безопасности (Refs: #70).
|
|
7
|
+
- Resilience: Трекер задержки семантического классификатора (`ClassifierLatencyTracker` в `lib/latency.js`) со скользящим окном p95/avg и автоматическим переходом на детерминированные правила при превышении SLA (`classifier-sla-fallback` -> `ask`) (Refs: #57).
|
|
8
|
+
- Resilience: Плавный fallback при несовместимости эндпоинта с OpenAI JSON-mode (`isShapeRejectError`), извлечение JSON из markdown/raw-ответов (`extractJsonPayload`) и повторный запрос без `response_format` для Ollama/vLLM (Refs: #59).
|
|
9
|
+
- Security: Строгая Schemastery-схема вердиктов классификатора (`ClassifierVerdictSchema`, `validateClassifierResponse`) с fail-closed защитой от некорректных/неполных ответов модели (`malformed-classifier-response` -> `ask`) (Refs: #66).
|
|
10
|
+
|
|
3
11
|
## 0.1.18
|
|
4
12
|
|
|
5
13
|
- Security: Эвристический предохранитель размера аргументов (`computePayloadSize` / `maxPayloadBytes`, по умолчанию 64 КБ) для предотвращения переполнения контекста LLM-ревьюера и обхода инспекции (`payload-too-large` -> `ask`) (Refs: #71).
|
package/lib/classifier.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { redactCredentials, redactUrl } from "./redact.js";
|
|
6
6
|
import { CircuitBreaker } from "./breaker.js";
|
|
7
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
7
8
|
|
|
8
9
|
// Additional regex patterns for sensitive tokens & API keys (#115)
|
|
9
10
|
const OPENAI_KEY_PATTERN = /\bsk-[a-zA-Z0-9_\-]{20,}\b/g;
|
|
@@ -244,7 +245,104 @@ export function isLowRiskExecution(execution) {
|
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
/**
|
|
247
|
-
*
|
|
248
|
+
* Checks whether an error is caused by endpoint rejection of JSON-mode / response_format (#59).
|
|
249
|
+
* Common in local LLM runtimes like Ollama, vLLM, and older endpoints.
|
|
250
|
+
*
|
|
251
|
+
* @param {any} err
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*/
|
|
254
|
+
export function isShapeRejectError(err) {
|
|
255
|
+
if (!err) return false;
|
|
256
|
+
const status = err.status || (err.response && err.response.status);
|
|
257
|
+
if (status === 400 || status === 415 || status === 422) return true;
|
|
258
|
+
const msg = String(err.message || "").toLowerCase();
|
|
259
|
+
return (
|
|
260
|
+
msg.includes("response_format") ||
|
|
261
|
+
msg.includes("json_object") ||
|
|
262
|
+
msg.includes("unsupported parameter") ||
|
|
263
|
+
msg.includes("schema")
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Robust JSON extraction from raw strings, handling markdown code blocks and raw JSON (#59).
|
|
269
|
+
*
|
|
270
|
+
* @param {string} content
|
|
271
|
+
* @returns {any | null}
|
|
272
|
+
*/
|
|
273
|
+
export function extractJsonPayload(content) {
|
|
274
|
+
if (!content || typeof content !== "string") return null;
|
|
275
|
+
const trimmed = content.trim();
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(trimmed);
|
|
278
|
+
} catch {
|
|
279
|
+
// Check markdown code block
|
|
280
|
+
const mdMatch = /```(?:json)?\s*([\s\S]*?)\s*```/i.exec(trimmed);
|
|
281
|
+
if (mdMatch && mdMatch[1]) {
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(mdMatch[1].trim());
|
|
284
|
+
} catch {
|
|
285
|
+
// continue
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Check bracketed JSON
|
|
289
|
+
const braceMatch = /\{[\s\S]*\}/.exec(trimmed);
|
|
290
|
+
if (braceMatch) {
|
|
291
|
+
try {
|
|
292
|
+
return JSON.parse(braceMatch[0]);
|
|
293
|
+
} catch {
|
|
294
|
+
// ignore
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Strict Schemastery schema for validating structured classifier decisions (#66).
|
|
303
|
+
*/
|
|
304
|
+
export const ClassifierVerdictSchema = Schema.object({
|
|
305
|
+
verdict: Schema.union(["allow", "deny", "ask"]).required(),
|
|
306
|
+
reason: Schema.string().default("classifier-evaluated"),
|
|
307
|
+
riskScore: Schema.number().default(0.5),
|
|
308
|
+
tags: Schema.array(Schema.string()).default([]),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Validates and normalizes structured classifier response (#66).
|
|
313
|
+
*
|
|
314
|
+
* @param {any} parsed
|
|
315
|
+
* @returns {{ valid: boolean, verdict?: "allow" | "deny" | "ask", reason?: string, riskScore?: number, tags?: string[], error?: string }}
|
|
316
|
+
*/
|
|
317
|
+
export function validateClassifierResponse(parsed) {
|
|
318
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
319
|
+
return { valid: false, error: "Response is not a valid JSON object" };
|
|
320
|
+
}
|
|
321
|
+
const rawVerdict = String(parsed.verdict || "").toLowerCase().trim();
|
|
322
|
+
if (!["allow", "deny", "ask"].includes(rawVerdict)) {
|
|
323
|
+
return { valid: false, error: `Invalid verdict '${parsed.verdict}', expected allow|deny|ask` };
|
|
324
|
+
}
|
|
325
|
+
const reason = typeof parsed.reason === "string" && parsed.reason.trim()
|
|
326
|
+
? parsed.reason.trim()
|
|
327
|
+
: `classifier-${rawVerdict}`;
|
|
328
|
+
const riskScore = typeof parsed.riskScore === "number" && !isNaN(parsed.riskScore)
|
|
329
|
+
? Math.max(0, Math.min(1, parsed.riskScore))
|
|
330
|
+
: (rawVerdict === "deny" ? 1.0 : (rawVerdict === "ask" ? 0.6 : 0.1));
|
|
331
|
+
const tags = Array.isArray(parsed.tags)
|
|
332
|
+
? parsed.tags.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim())
|
|
333
|
+
: [];
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
valid: true,
|
|
337
|
+
verdict: rawVerdict,
|
|
338
|
+
reason,
|
|
339
|
+
riskScore,
|
|
340
|
+
tags,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Standalone resilient LLM Classifier client (#127, #129, #118, #115, #117, #59, #66).
|
|
248
346
|
*/
|
|
249
347
|
export class LlmClassifier {
|
|
250
348
|
/**
|
|
@@ -256,6 +354,7 @@ export class LlmClassifier {
|
|
|
256
354
|
* timeoutPolicy?: "strict" | "permissive",
|
|
257
355
|
* maxRetries?: number,
|
|
258
356
|
* maxContextChars?: number,
|
|
357
|
+
* supportsJsonMode?: boolean,
|
|
259
358
|
* fetch?: typeof globalThis.fetch
|
|
260
359
|
* }} [options]
|
|
261
360
|
*/
|
|
@@ -267,11 +366,12 @@ export class LlmClassifier {
|
|
|
267
366
|
this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
|
|
268
367
|
this.maxRetries = typeof options.maxRetries === "number" ? options.maxRetries : 2;
|
|
269
368
|
this.maxContextChars = typeof options.maxContextChars === "number" ? options.maxContextChars : 1000;
|
|
369
|
+
this.supportsJsonMode = options.supportsJsonMode !== false;
|
|
270
370
|
this.fetch = options.fetch || globalThis.fetch;
|
|
271
371
|
}
|
|
272
372
|
|
|
273
373
|
/**
|
|
274
|
-
* Builds prompt messages with sanitized credentials and bounded context size (#115, #117).
|
|
374
|
+
* Builds prompt messages with sanitized credentials and bounded context size (#115, #117, #66).
|
|
275
375
|
*
|
|
276
376
|
* @param {any} execution
|
|
277
377
|
* @param {any} [context]
|
|
@@ -286,7 +386,7 @@ export class LlmClassifier {
|
|
|
286
386
|
role: "system",
|
|
287
387
|
content:
|
|
288
388
|
"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>\"}.",
|
|
389
|
+
"Respond strictly in valid JSON format: {\"verdict\": \"allow\"|\"deny\"|\"ask\", \"reason\": \"<short explanation>\", \"riskScore\": <0.0-1.0>, \"tags\": [\"<tag>\"]}.",
|
|
290
390
|
},
|
|
291
391
|
{
|
|
292
392
|
role: "user",
|
|
@@ -296,11 +396,11 @@ export class LlmClassifier {
|
|
|
296
396
|
}
|
|
297
397
|
|
|
298
398
|
/**
|
|
299
|
-
* Classifies an execution request using the configured LLM endpoint (#118, #127, #129, #115, #117).
|
|
399
|
+
* Classifies an execution request using the configured LLM endpoint (#118, #127, #129, #115, #117, #59, #66).
|
|
300
400
|
*
|
|
301
401
|
* @param {any} execution
|
|
302
402
|
* @param {any} [context]
|
|
303
|
-
* @returns {Promise<{ verdict: "allow" | "deny" | "ask", reason: string, timeout?: boolean, error?: any }>}
|
|
403
|
+
* @returns {Promise<{ verdict: "allow" | "deny" | "ask", reason: string, riskScore?: number, tags?: string[], timeout?: boolean, error?: any }>}
|
|
304
404
|
*/
|
|
305
405
|
async classify(execution, context = {}) {
|
|
306
406
|
if (!this.endpoint || typeof this.fetch !== "function") {
|
|
@@ -308,15 +408,19 @@ export class LlmClassifier {
|
|
|
308
408
|
}
|
|
309
409
|
|
|
310
410
|
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
411
|
|
|
318
|
-
|
|
319
|
-
const
|
|
412
|
+
const callApi = async (useJsonMode) => {
|
|
413
|
+
const payload = {
|
|
414
|
+
model: this.model,
|
|
415
|
+
messages,
|
|
416
|
+
temperature: 0.1,
|
|
417
|
+
};
|
|
418
|
+
if (useJsonMode) {
|
|
419
|
+
payload.response_format = { type: "json_object" };
|
|
420
|
+
}
|
|
421
|
+
const body = JSON.stringify(payload);
|
|
422
|
+
|
|
423
|
+
return withLlmRetry(
|
|
320
424
|
async () => {
|
|
321
425
|
const controller = new AbortController();
|
|
322
426
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -354,23 +458,49 @@ export class LlmClassifier {
|
|
|
354
458
|
retryOn: shouldRetryLlmError,
|
|
355
459
|
}
|
|
356
460
|
);
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
let response;
|
|
465
|
+
if (this.supportsJsonMode) {
|
|
466
|
+
try {
|
|
467
|
+
response = await callApi(true);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
if (isShapeRejectError(err)) {
|
|
470
|
+
this.supportsJsonMode = false;
|
|
471
|
+
response = await callApi(false);
|
|
472
|
+
} else {
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
response = await callApi(false);
|
|
478
|
+
}
|
|
357
479
|
|
|
358
480
|
const content = response?.choices?.[0]?.message?.content;
|
|
359
481
|
if (!content) {
|
|
360
482
|
return { verdict: "ask", reason: "classifier-empty-response" };
|
|
361
483
|
}
|
|
362
484
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
parsed = JSON.parse(content);
|
|
366
|
-
} catch {
|
|
485
|
+
const parsed = extractJsonPayload(content);
|
|
486
|
+
if (!parsed) {
|
|
367
487
|
return { verdict: "ask", reason: "classifier-invalid-json" };
|
|
368
488
|
}
|
|
369
489
|
|
|
370
|
-
const
|
|
490
|
+
const validated = validateClassifierResponse(parsed);
|
|
491
|
+
if (!validated.valid) {
|
|
492
|
+
return {
|
|
493
|
+
verdict: "ask",
|
|
494
|
+
reason: "malformed-classifier-response",
|
|
495
|
+
detail: validated.error,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
371
499
|
return {
|
|
372
|
-
verdict,
|
|
373
|
-
reason:
|
|
500
|
+
verdict: validated.verdict,
|
|
501
|
+
reason: validated.reason,
|
|
502
|
+
riskScore: validated.riskScore,
|
|
503
|
+
tags: validated.tags,
|
|
374
504
|
};
|
|
375
505
|
} catch (err) {
|
|
376
506
|
const isTimeout = CircuitBreaker.isFaultOrTimeout(err) || Boolean(err?.isTimeout);
|
package/lib/engine.js
CHANGED
|
@@ -20,6 +20,8 @@ import { BlockCounters } from "./counters.js";
|
|
|
20
20
|
import { ContractGuard } from "./contract-guard.js";
|
|
21
21
|
import { OverengineeringGuard } from "./overengineering.js";
|
|
22
22
|
import { extractFilePath, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
|
|
23
|
+
import { categorizeRiskBucket, extractRiskTags } from "./risk.js";
|
|
24
|
+
import { ClassifierLatencyTracker } from "./latency.js";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
|
|
@@ -123,6 +125,7 @@ export class SecurityEngine {
|
|
|
123
125
|
: (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined
|
|
124
126
|
? new OverengineeringGuard(options)
|
|
125
127
|
: null);
|
|
128
|
+
this.latencyTracker = options.latencyTracker || new ClassifierLatencyTracker(options);
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
/**
|
|
@@ -152,9 +155,9 @@ export class SecurityEngine {
|
|
|
152
155
|
if (this.auditLogger) this.auditLogger.appendAuditLine({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
|
|
153
156
|
|
|
154
157
|
if (this.dryRun) {
|
|
155
|
-
return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
158
|
+
return this._enrichDecision({ verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit }, execution, context);
|
|
156
159
|
}
|
|
157
|
-
return exhDecision;
|
|
160
|
+
return this._enrichDecision(exhDecision, execution, context);
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
// Check Verdict Cache (#63, #119)
|
|
@@ -163,7 +166,7 @@ export class SecurityEngine {
|
|
|
163
166
|
const cached = this.cache.get(cacheKey);
|
|
164
167
|
if (cached) {
|
|
165
168
|
if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
|
|
166
|
-
return {
|
|
169
|
+
return this._enrichDecision({
|
|
167
170
|
verdict: "allow",
|
|
168
171
|
stage: cached.stage,
|
|
169
172
|
dryRun: true,
|
|
@@ -172,14 +175,15 @@ export class SecurityEngine {
|
|
|
172
175
|
detail: cached.detail,
|
|
173
176
|
hit: cached.hit,
|
|
174
177
|
cached: true,
|
|
175
|
-
};
|
|
178
|
+
}, execution, context);
|
|
176
179
|
}
|
|
177
180
|
if (this.counters) this.counters.record(cached, execution);
|
|
178
|
-
return cached;
|
|
181
|
+
return this._enrichDecision(cached, execution, context);
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
|
|
186
|
+
this._enrichDecision(decision, execution, context);
|
|
183
187
|
|
|
184
188
|
// Post-decision hooks: ledger, retryGuard, cache
|
|
185
189
|
if (decision.verdict === "deny") {
|
|
@@ -212,6 +216,7 @@ export class SecurityEngine {
|
|
|
212
216
|
hit: auditDenyHit,
|
|
213
217
|
auditFailed: true,
|
|
214
218
|
};
|
|
219
|
+
this._enrichDecision(decision, execution, context);
|
|
215
220
|
}
|
|
216
221
|
}
|
|
217
222
|
|
|
@@ -222,7 +227,7 @@ export class SecurityEngine {
|
|
|
222
227
|
|
|
223
228
|
// Shadow Mode (#128)
|
|
224
229
|
if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
|
|
225
|
-
return {
|
|
230
|
+
return this._enrichDecision({
|
|
226
231
|
verdict: "allow",
|
|
227
232
|
stage: decision.stage,
|
|
228
233
|
dryRun: true,
|
|
@@ -231,7 +236,7 @@ export class SecurityEngine {
|
|
|
231
236
|
detail: decision.detail,
|
|
232
237
|
hit: decision.hit,
|
|
233
238
|
lockedAsk: decision.lockedAsk,
|
|
234
|
-
};
|
|
239
|
+
}, execution, context);
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
return decision;
|
|
@@ -244,12 +249,12 @@ export class SecurityEngine {
|
|
|
244
249
|
hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
|
|
245
250
|
};
|
|
246
251
|
if (this.counters) this.counters.record(errDecision, execution);
|
|
247
|
-
return errDecision;
|
|
252
|
+
return this._enrichDecision(errDecision, execution, context);
|
|
248
253
|
}
|
|
249
254
|
}
|
|
250
255
|
|
|
251
256
|
/**
|
|
252
|
-
* Asynchronous evaluation supporting async classifiers (#129, #118).
|
|
257
|
+
* Asynchronous evaluation supporting async classifiers and latency SLA tracking (#129, #118, #57).
|
|
253
258
|
*
|
|
254
259
|
* @param {{ name: string, arguments?: any }} execution
|
|
255
260
|
* @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context]
|
|
@@ -259,15 +264,31 @@ export class SecurityEngine {
|
|
|
259
264
|
if (this.classifier) {
|
|
260
265
|
let resolvedVerdict = null;
|
|
261
266
|
let caughtError = null;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
267
|
+
|
|
268
|
+
if (this.latencyTracker && this.latencyTracker.isDegraded()) {
|
|
269
|
+
resolvedVerdict = {
|
|
270
|
+
verdict: "ask",
|
|
271
|
+
reason: "classifier-sla-fallback",
|
|
272
|
+
detail: `Classifier latency p95 (${this.latencyTracker.getP95()}ms) exceeded SLA (${this.latencyTracker.slaMs}ms) (#57)`,
|
|
273
|
+
};
|
|
274
|
+
} else {
|
|
275
|
+
const start = Date.now();
|
|
276
|
+
try {
|
|
277
|
+
if (typeof this.classifier.classify === "function") {
|
|
278
|
+
resolvedVerdict = await this.classifier.classify(execution, context);
|
|
279
|
+
} else if (typeof this.classifier === "function") {
|
|
280
|
+
const res = this.classifier(execution, context);
|
|
281
|
+
resolvedVerdict = res && typeof res.then === "function" ? await res : res;
|
|
282
|
+
}
|
|
283
|
+
if (this.latencyTracker) {
|
|
284
|
+
this.latencyTracker.recordSample(Date.now() - start);
|
|
285
|
+
}
|
|
286
|
+
} catch (err) {
|
|
287
|
+
caughtError = err;
|
|
288
|
+
if (this.latencyTracker) {
|
|
289
|
+
this.latencyTracker.recordSample(Date.now() - start);
|
|
290
|
+
}
|
|
268
291
|
}
|
|
269
|
-
} catch (err) {
|
|
270
|
-
caughtError = err;
|
|
271
292
|
}
|
|
272
293
|
|
|
273
294
|
const originalClassifier = this.classifier;
|
|
@@ -509,4 +530,28 @@ export class SecurityEngine {
|
|
|
509
530
|
if (this.cache) this.cache.clearSession(sessionId);
|
|
510
531
|
return this.breaker.resume(sessionId, reason);
|
|
511
532
|
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Enriches decision with risk bucket (#58) and operational risk tags (#70).
|
|
536
|
+
* @private
|
|
537
|
+
*/
|
|
538
|
+
_enrichDecision(decision, execution, context) {
|
|
539
|
+
if (!decision) return decision;
|
|
540
|
+
if (!decision.riskBucket) {
|
|
541
|
+
decision.riskBucket = categorizeRiskBucket(execution, context);
|
|
542
|
+
}
|
|
543
|
+
if (!decision.riskTags || !Array.isArray(decision.riskTags)) {
|
|
544
|
+
decision.riskTags = extractRiskTags(execution, context);
|
|
545
|
+
}
|
|
546
|
+
return decision;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
getLatencyMetrics() {
|
|
550
|
+
return this.latencyTracker ? this.latencyTracker.getMetrics() : null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
resetLatencyTracker() {
|
|
554
|
+
if (this.latencyTracker) this.latencyTracker.reset();
|
|
555
|
+
}
|
|
512
556
|
}
|
|
557
|
+
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { BandsEngine } from "./bands.js";
|
|
|
3
3
|
import { AuditLogger } from "./audit-log.js";
|
|
4
4
|
import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
5
5
|
import { LlmClassifier } from "./classifier.js";
|
|
6
|
+
import { ClassifierLatencyTracker } from "./latency.js";
|
|
6
7
|
import Schema from "@deepseek-ai/schemastery";
|
|
7
8
|
|
|
8
9
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -236,6 +237,8 @@ export const Config = Schema.object({
|
|
|
236
237
|
isolateTempRoots: Schema.boolean().default(true).volatile(),
|
|
237
238
|
/** Maximum allowed created files per session to prevent overengineering (#89). */
|
|
238
239
|
maxFilesPerSession: Schema.number().default(10).volatile(),
|
|
240
|
+
/** Maximum latency in ms before classifier degrades to deterministic rules (#57). */
|
|
241
|
+
classifierSlaMs: Schema.number().default(2000).volatile(),
|
|
239
242
|
/** Task contract boundary rules (#87). */
|
|
240
243
|
taskContract: Schema.any().volatile(),
|
|
241
244
|
/** Security engine instance (internal/testing). */
|
|
@@ -301,6 +304,9 @@ export function apply(ctx, config) {
|
|
|
301
304
|
bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
|
|
302
305
|
classifier,
|
|
303
306
|
timeoutPolicy: cfg.classifierTimeoutPolicy || "strict",
|
|
307
|
+
latencyTracker: new ClassifierLatencyTracker({
|
|
308
|
+
slaMs: cfg.classifierSlaMs !== undefined ? cfg.classifierSlaMs : 2000,
|
|
309
|
+
}),
|
|
304
310
|
defaultVerdict: cfg.defaultVerdict || "allow",
|
|
305
311
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
306
312
|
auditFailClosed: Boolean(cfg.auditFailClosed),
|
|
@@ -326,6 +332,8 @@ export function apply(ctx, config) {
|
|
|
326
332
|
getCounters: () => engine.getCounters(),
|
|
327
333
|
resetCounters: () => engine.resetCounters(),
|
|
328
334
|
getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
|
|
335
|
+
getLatencyMetrics: () => (engine.getLatencyMetrics ? engine.getLatencyMetrics() : null),
|
|
336
|
+
resetLatencyTracker: () => (engine.resetLatencyTracker ? engine.resetLatencyTracker() : undefined),
|
|
329
337
|
getSnapshot: () => ({
|
|
330
338
|
active: true,
|
|
331
339
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
@@ -336,6 +344,7 @@ export function apply(ctx, config) {
|
|
|
336
344
|
rulesCount: engine.declarativeRules ? engine.declarativeRules.count() : 0,
|
|
337
345
|
cacheStats: engine.cache ? engine.cache.stats() : null,
|
|
338
346
|
breakerState: engine.breaker ? engine.breaker.getState() : null,
|
|
347
|
+
latencyMetrics: engine.getLatencyMetrics ? engine.getLatencyMetrics() : null,
|
|
339
348
|
}),
|
|
340
349
|
};
|
|
341
350
|
if (ctx && typeof ctx === "object") {
|
|
@@ -536,27 +545,11 @@ export {
|
|
|
536
545
|
DEFAULT_RETAIN_LINES,
|
|
537
546
|
auditRotateContent,
|
|
538
547
|
} from "./audit-log.js";
|
|
539
|
-
export {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
} from "./
|
|
543
|
-
export {
|
|
544
|
-
isCriticalOperation,
|
|
545
|
-
} from "./engine.js";
|
|
546
|
-
export {
|
|
547
|
-
parseYaml,
|
|
548
|
-
parseScalar,
|
|
549
|
-
stripComment,
|
|
550
|
-
} from "./yaml-parser.js";
|
|
551
|
-
export {
|
|
552
|
-
matchConditionValue,
|
|
553
|
-
evaluateWhen,
|
|
554
|
-
} from "./when-matcher.js";
|
|
555
|
-
export {
|
|
556
|
-
HotwordsMatcher,
|
|
557
|
-
DEFAULT_HOTWORDS,
|
|
558
|
-
extractWords,
|
|
559
|
-
} from "./hotwords.js";
|
|
548
|
+
export { BlockCounters, categorizeReason } from "./counters.js";
|
|
549
|
+
export { isCriticalOperation } from "./engine.js";
|
|
550
|
+
export { parseYaml, parseScalar, stripComment } from "./yaml-parser.js";
|
|
551
|
+
export { matchConditionValue, evaluateWhen } from "./when-matcher.js";
|
|
552
|
+
export { HotwordsMatcher, DEFAULT_HOTWORDS, extractWords } from "./hotwords.js";
|
|
560
553
|
export {
|
|
561
554
|
DeclarativeRulesEngine,
|
|
562
555
|
BUILTIN_RULES_FILE,
|
|
@@ -569,9 +562,16 @@ export {
|
|
|
569
562
|
sanitizeClassifierInput,
|
|
570
563
|
truncateClassifierContext,
|
|
571
564
|
isLowRiskExecution,
|
|
565
|
+
isShapeRejectError,
|
|
566
|
+
extractJsonPayload,
|
|
567
|
+
ClassifierVerdictSchema,
|
|
568
|
+
validateClassifierResponse,
|
|
572
569
|
LlmClassifier,
|
|
573
570
|
} from "./classifier.js";
|
|
574
571
|
export { ContractGuard } from "./contract-guard.js";
|
|
575
572
|
export { OverengineeringGuard, DEFAULT_MAX_FILES_PER_SESSION } from "./overengineering.js";
|
|
576
573
|
export { resolveTempRoot, isGlobalTempPath, checkTempIsolation } from "./paths.js";
|
|
577
574
|
export { computePayloadSize, scanPromptInjection } from "./inspect.js";
|
|
575
|
+
export { RISK_BUCKETS, RISK_TAGS, categorizeRiskBucket, extractRiskTags } from "./risk.js";
|
|
576
|
+
export { ClassifierLatencyTracker, DEFAULT_CLASSIFIER_SLA_MS } from "./latency.js";
|
|
577
|
+
|
package/lib/latency.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// lib/latency.js
|
|
2
|
+
// Classifier latency tracking and SLA-based fallback on deterministic rules (#57).
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CLASSIFIER_SLA_MS = 2000;
|
|
5
|
+
export const DEFAULT_LATENCY_WINDOW_SIZE = 50;
|
|
6
|
+
export const DEFAULT_DEGRADE_THRESHOLD_COUNT = 3;
|
|
7
|
+
export const DEFAULT_RECOVERY_THRESHOLD_COUNT = 2;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tracks external semantic classifier latency with sliding window statistics and SLA health (#57).
|
|
11
|
+
*/
|
|
12
|
+
export class ClassifierLatencyTracker {
|
|
13
|
+
/**
|
|
14
|
+
* @param {{
|
|
15
|
+
* slaMs?: number,
|
|
16
|
+
* windowSize?: number,
|
|
17
|
+
* degradeThresholdCount?: number,
|
|
18
|
+
* recoveryThresholdCount?: number
|
|
19
|
+
* }} [options]
|
|
20
|
+
*/
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.slaMs = typeof options.slaMs === "number" && options.slaMs > 0
|
|
23
|
+
? options.slaMs
|
|
24
|
+
: DEFAULT_CLASSIFIER_SLA_MS;
|
|
25
|
+
this.windowSize = typeof options.windowSize === "number" && options.windowSize > 0
|
|
26
|
+
? options.windowSize
|
|
27
|
+
: DEFAULT_LATENCY_WINDOW_SIZE;
|
|
28
|
+
this.degradeThresholdCount = typeof options.degradeThresholdCount === "number" && options.degradeThresholdCount > 0
|
|
29
|
+
? options.degradeThresholdCount
|
|
30
|
+
: DEFAULT_DEGRADE_THRESHOLD_COUNT;
|
|
31
|
+
this.recoveryThresholdCount = typeof options.recoveryThresholdCount === "number" && options.recoveryThresholdCount > 0
|
|
32
|
+
? options.recoveryThresholdCount
|
|
33
|
+
: DEFAULT_RECOVERY_THRESHOLD_COUNT;
|
|
34
|
+
|
|
35
|
+
this._samples = [];
|
|
36
|
+
this._consecutiveSlow = 0;
|
|
37
|
+
this._consecutiveFast = 0;
|
|
38
|
+
this._degraded = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Records a latency observation in milliseconds.
|
|
43
|
+
*
|
|
44
|
+
* @param {number} durationMs
|
|
45
|
+
* @returns {{ degraded: boolean, p95Ms: number, avgMs: number }}
|
|
46
|
+
*/
|
|
47
|
+
recordSample(durationMs) {
|
|
48
|
+
const val = Math.max(0, Number(durationMs) || 0);
|
|
49
|
+
this._samples.push(val);
|
|
50
|
+
if (this._samples.length > this.windowSize) {
|
|
51
|
+
this._samples.shift();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (val > this.slaMs) {
|
|
55
|
+
this._consecutiveSlow += 1;
|
|
56
|
+
this._consecutiveFast = 0;
|
|
57
|
+
if (this._consecutiveSlow >= this.degradeThresholdCount) {
|
|
58
|
+
this._degraded = true;
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
this._consecutiveFast += 1;
|
|
62
|
+
this._consecutiveSlow = 0;
|
|
63
|
+
if (this._consecutiveFast >= this.recoveryThresholdCount) {
|
|
64
|
+
this._degraded = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!this._degraded && this._samples.length >= 5 && this.getP95() > this.slaMs && this._consecutiveSlow > 0) {
|
|
69
|
+
this._degraded = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
degraded: this.isDegraded(),
|
|
74
|
+
p95Ms: this.getP95(),
|
|
75
|
+
avgMs: this.getAvg(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Calculates 95th percentile latency from the current sliding window.
|
|
81
|
+
* @returns {number}
|
|
82
|
+
*/
|
|
83
|
+
getP95() {
|
|
84
|
+
if (this._samples.length === 0) return 0;
|
|
85
|
+
const sorted = this._samples.slice().sort((a, b) => a - b);
|
|
86
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * 0.95) - 1));
|
|
87
|
+
return Math.round(sorted[idx] * 10) / 10;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Calculates mean latency from current window.
|
|
92
|
+
* @returns {number}
|
|
93
|
+
*/
|
|
94
|
+
getAvg() {
|
|
95
|
+
if (this._samples.length === 0) return 0;
|
|
96
|
+
const sum = this._samples.reduce((acc, v) => acc + v, 0);
|
|
97
|
+
return Math.round((sum / this._samples.length) * 10) / 10;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Checks whether the classifier is currently marked degraded due to SLA violations.
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
isDegraded() {
|
|
105
|
+
return this._degraded;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resets all tracked samples and returns to healthy state.
|
|
110
|
+
*/
|
|
111
|
+
reset() {
|
|
112
|
+
this._samples = [];
|
|
113
|
+
this._consecutiveSlow = 0;
|
|
114
|
+
this._consecutiveFast = 0;
|
|
115
|
+
this._degraded = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Returns a snapshot of current latency metrics.
|
|
120
|
+
* @returns {{ count: number, avgMs: number, p95Ms: number, minMs: number, maxMs: number, degraded: boolean, slaMs: number, consecutiveSlow: number }}
|
|
121
|
+
*/
|
|
122
|
+
getMetrics() {
|
|
123
|
+
const count = this._samples.length;
|
|
124
|
+
const minMs = count > 0 ? Math.min(...this._samples) : 0;
|
|
125
|
+
const maxMs = count > 0 ? Math.max(...this._samples) : 0;
|
|
126
|
+
return {
|
|
127
|
+
count,
|
|
128
|
+
avgMs: this.getAvg(),
|
|
129
|
+
p95Ms: this.getP95(),
|
|
130
|
+
minMs,
|
|
131
|
+
maxMs,
|
|
132
|
+
degraded: this.isDegraded(),
|
|
133
|
+
slaMs: this.slaMs,
|
|
134
|
+
consecutiveSlow: this._consecutiveSlow,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
package/lib/messages.js
CHANGED
|
@@ -67,6 +67,8 @@ export const MESSAGES = Object.freeze({
|
|
|
67
67
|
ruleClassifierTimeoutPermissive: "Semantic classifier timed out; allowed by low-risk fallback policy",
|
|
68
68
|
askClassifierTimeout: "Semantic classifier request timed out; human confirmation required",
|
|
69
69
|
askClassifierError: "Semantic classifier error; human confirmation required",
|
|
70
|
+
askClassifierSlaFallback: "Semantic classifier exceeded SLA latency threshold; manual review required",
|
|
71
|
+
askMalformedClassifierResponse: "Semantic classifier returned malformed or unverified decision; manual review required",
|
|
70
72
|
askPayloadTooLarge: "Command arguments exceed maximum payload size limit; review before allowing",
|
|
71
73
|
ruleReviewerInjectionAttempt: "Prompt instruction injection detected in tool arguments",
|
|
72
74
|
askContractViolation: "Operation falls outside task contract boundaries; review before allowing",
|
|
@@ -91,6 +93,8 @@ export const MESSAGES = Object.freeze({
|
|
|
91
93
|
hintFailClosedAudit: "Ensure the audit log directory is writable and disk space is available",
|
|
92
94
|
hintLockedAskUnattended: "Switch to an interactive session or request explicit human confirmation",
|
|
93
95
|
hintClassifierTimeout: "Check external LLM connectivity or review timeout policy settings",
|
|
96
|
+
hintClassifierSlaFallback: "Check external LLM classifier load or increase classifierSlaMs",
|
|
97
|
+
hintMalformedClassifierResponse: "Verify model JSON output format or check response schema compatibility",
|
|
94
98
|
hintPayloadTooLarge: "Reduce input payload size or chunk data into smaller batches",
|
|
95
99
|
hintReviewerInjectionAttempt: "Remove adversarial prompt override directives from input",
|
|
96
100
|
hintContractViolation: "Confine operations to permitted task tools and project scope",
|
|
@@ -162,6 +166,8 @@ export const MESSAGES = Object.freeze({
|
|
|
162
166
|
ruleClassifierTimeoutPermissive: "语义分类器超时;根据低风险容错策略放行",
|
|
163
167
|
askClassifierTimeout: "语义分类器请求超时;需要人工审查",
|
|
164
168
|
askClassifierError: "语义分类器发生异常;需要人工审查",
|
|
169
|
+
askClassifierSlaFallback: "语义分类器延迟超出 SLA 阈值;需要人工审查",
|
|
170
|
+
askMalformedClassifierResponse: "语义分类器返回非预期或未通过校验的判定;需要人工审查",
|
|
165
171
|
askPayloadTooLarge: "命令参数超过最大负载大小限制;请在允许前进行审查",
|
|
166
172
|
ruleReviewerInjectionAttempt: "在工具参数中检测到提示词指令注入尝试",
|
|
167
173
|
askContractViolation: "操作超出任务契约范围;请在允许前进行审查",
|
|
@@ -186,6 +192,8 @@ export const MESSAGES = Object.freeze({
|
|
|
186
192
|
hintFailClosedAudit: "请确保审计日志目录可写且磁盘空间充足",
|
|
187
193
|
hintLockedAskUnattended: "请切换到交互式会话或请求人工显式确认",
|
|
188
194
|
hintClassifierTimeout: "请检查外部 LLM 服务连通性或调整分类器超时容错策略",
|
|
195
|
+
hintClassifierSlaFallback: "请检查外部 LLM 分类器负载或调高 classifierSlaMs 阈值",
|
|
196
|
+
hintMalformedClassifierResponse: "请检查模型 JSON 输出格式或验证返回模式兼容性",
|
|
189
197
|
hintPayloadTooLarge: "减小输入参数大小或分批传输数据",
|
|
190
198
|
hintReviewerInjectionAttempt: "从输入中移除对抗性指令覆盖指令",
|
|
191
199
|
hintContractViolation: "将操作限制在允许的任务工具和项目范围内",
|
|
@@ -256,6 +264,8 @@ const RULE_KEYS = Object.freeze({
|
|
|
256
264
|
"classifier-timeout-strict": "ruleClassifierTimeoutStrict",
|
|
257
265
|
"classifier-timeout-fallback-strict": "ruleClassifierTimeoutStrict",
|
|
258
266
|
"classifier-timeout-fallback-permissive": "ruleClassifierTimeoutPermissive",
|
|
267
|
+
"classifier-sla-fallback": "askClassifierSlaFallback",
|
|
268
|
+
"malformed-classifier-response": "askMalformedClassifierResponse",
|
|
259
269
|
"reviewer-injection-attempt": "ruleReviewerInjectionAttempt",
|
|
260
270
|
"contract-violation": "ruleContractViolation",
|
|
261
271
|
"unisolated-temp-path": "ruleUnisolatedTempPath",
|
|
@@ -280,6 +290,8 @@ const HINT_KEYS = Object.freeze({
|
|
|
280
290
|
"locked-ask-unattended-denied": "hintLockedAskUnattended",
|
|
281
291
|
"classifier-timeout-strict": "hintClassifierTimeout",
|
|
282
292
|
"classifier-timeout-fallback-strict": "hintClassifierTimeout",
|
|
293
|
+
"classifier-sla-fallback": "hintClassifierSlaFallback",
|
|
294
|
+
"malformed-classifier-response": "hintMalformedClassifierResponse",
|
|
283
295
|
"payload-too-large": "hintPayloadTooLarge",
|
|
284
296
|
"reviewer-injection-attempt": "hintReviewerInjectionAttempt",
|
|
285
297
|
"contract-violation": "hintContractViolation",
|
|
@@ -303,6 +315,8 @@ const ASK_KEYS = Object.freeze({
|
|
|
303
315
|
"classifier-timeout-strict": "askClassifierTimeout",
|
|
304
316
|
"classifier-timeout-fallback-strict": "askClassifierTimeout",
|
|
305
317
|
"classifier-error": "askClassifierError",
|
|
318
|
+
"classifier-sla-fallback": "askClassifierSlaFallback",
|
|
319
|
+
"malformed-classifier-response": "askMalformedClassifierResponse",
|
|
306
320
|
"payload-too-large": "askPayloadTooLarge",
|
|
307
321
|
"contract-violation": "askContractViolation",
|
|
308
322
|
"unisolated-temp-path": "askUnisolatedTempPath",
|
package/lib/risk.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// lib/risk.js
|
|
2
|
+
// Structured risk bucket classification and operational risk tagging.
|
|
3
|
+
// Resolves: #58 (Risk Buckets) and #70 (Risk Tags)
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_BASH_TOOL,
|
|
7
|
+
DEFAULT_FILE_WRITE_TOOLS,
|
|
8
|
+
DEFAULT_READ_ONLY_TOOLS,
|
|
9
|
+
DEFAULT_NETWORK_TOOLS,
|
|
10
|
+
extractFilePath,
|
|
11
|
+
} from "./dispatcher.js";
|
|
12
|
+
import { isProtectedConfigPath } from "./paths.js";
|
|
13
|
+
import { isLoopbackHost, isLoopbackUrl } from "./loopback.js";
|
|
14
|
+
|
|
15
|
+
export const RISK_BUCKETS = Object.freeze({
|
|
16
|
+
READ_ONLY: "READ_ONLY",
|
|
17
|
+
WORKSPACE: "WORKSPACE",
|
|
18
|
+
EGRESS: "EGRESS",
|
|
19
|
+
SYSTEM: "SYSTEM",
|
|
20
|
+
SECRETS: "SECRETS",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const RISK_TAGS = Object.freeze({
|
|
24
|
+
READ_ONLY: "read-only",
|
|
25
|
+
FILE_MUTATION: "file-mutation",
|
|
26
|
+
NETWORK: "network",
|
|
27
|
+
GIT: "git",
|
|
28
|
+
CREDENTIALS: "credentials",
|
|
29
|
+
PACKAGE_MANAGER: "package-manager",
|
|
30
|
+
SYSTEM_MUTATION: "system-mutation",
|
|
31
|
+
PRIVILEGE_ESCALATION: "privilege-escalation",
|
|
32
|
+
DESTRUCTIVE: "destructive",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const READ_ONLY_CMDS = new Set([
|
|
36
|
+
"ls", "cat", "head", "tail", "grep", "find", "pwd", "which", "whereis",
|
|
37
|
+
"file", "stat", "echo", "printf", "diff", "wc", "sort", "uniq", "date",
|
|
38
|
+
"uname", "uptime", "hostname", "whoami", "id", "node -v", "npm -v", "git status",
|
|
39
|
+
"git log", "git diff", "git show", "git rev-parse", "git branch",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const SYSTEM_PKG_MANAGERS = new Set([
|
|
43
|
+
"apt", "apt-get", "dpkg", "yum", "dnf", "pacman", "zypper", "brew", "snap", "flatpak", "apk",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const DEV_PKG_MANAGERS = new Set(["npm", "pnpm", "yarn", "pip", "pip3", "cargo", "gem", "go", "composer"]);
|
|
47
|
+
|
|
48
|
+
const SYSTEM_SERVICES = new Set(["systemctl", "service", "init", "reboot", "shutdown", "poweroff", "journalctl"]);
|
|
49
|
+
|
|
50
|
+
const NETWORK_CMDS = new Set(["curl", "wget", "fetch", "nc", "netcat", "ncat", "ssh", "scp", "rsync", "ping", "telnet"]);
|
|
51
|
+
|
|
52
|
+
const SECRET_PATTERNS = [
|
|
53
|
+
/(\.env|\.env\.[a-z0-9_.-]+)$/i,
|
|
54
|
+
/id_rsa|id_ed25519|authorized_keys|known_hosts/i,
|
|
55
|
+
/\/etc\/(shadow|sudoers|master\.passwd)/i,
|
|
56
|
+
/\.git-credentials|\.netrc|\.dockercfg|\.npmrc/i,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Categorizes an execution into one of the 5 standardized risk buckets (#58).
|
|
61
|
+
* Priority waterfall: SECRETS -> SYSTEM -> EGRESS -> WORKSPACE -> READ_ONLY
|
|
62
|
+
*
|
|
63
|
+
* @param {any} execution
|
|
64
|
+
* @param {any} [context]
|
|
65
|
+
* @returns {string} Bucket name from RISK_BUCKETS
|
|
66
|
+
*/
|
|
67
|
+
export function categorizeRiskBucket(execution, context = {}) {
|
|
68
|
+
const toolName = execution && execution.name;
|
|
69
|
+
const args = execution && execution.arguments;
|
|
70
|
+
const command = (args && typeof args === "object" && typeof args.command === "string")
|
|
71
|
+
? args.command.trim()
|
|
72
|
+
: (typeof args === "string" ? args.trim() : "");
|
|
73
|
+
const targetPath = extractFilePath(args);
|
|
74
|
+
|
|
75
|
+
// 1. SECRETS check
|
|
76
|
+
if (targetPath) {
|
|
77
|
+
if (isProtectedConfigPath(targetPath) || SECRET_PATTERNS.some((p) => p.test(targetPath))) {
|
|
78
|
+
return RISK_BUCKETS.SECRETS;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (command) {
|
|
82
|
+
if (/\b(?:printenv|export|env)\b(?!\s*=[a-zA-Z0-9_.-]+)/i.test(command) && !/\b(?:npm|node|git|test|cargo)\b/i.test(command)) {
|
|
83
|
+
return RISK_BUCKETS.SECRETS;
|
|
84
|
+
}
|
|
85
|
+
if (SECRET_PATTERNS.some((p) => p.test(command))) {
|
|
86
|
+
return RISK_BUCKETS.SECRETS;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. SYSTEM check
|
|
91
|
+
if (command) {
|
|
92
|
+
const firstWord = command.split(/\s+/)[0].replace(/^sudo\s+/, "");
|
|
93
|
+
if (/^(sudo|su|doas)\b/i.test(command)) return RISK_BUCKETS.SYSTEM;
|
|
94
|
+
if (SYSTEM_PKG_MANAGERS.has(firstWord) || SYSTEM_SERVICES.has(firstWord)) return RISK_BUCKETS.SYSTEM;
|
|
95
|
+
if (/\b(chmod|chown)\s+(-[a-zA-Z]*\s+)?[0-7]{3,4}\b/i.test(command)) return RISK_BUCKETS.SYSTEM;
|
|
96
|
+
if (/\b(mkfs|fdisk|parted|mount|umount)\b/i.test(command)) return RISK_BUCKETS.SYSTEM;
|
|
97
|
+
if (/\/(etc|usr|var|boot|sys|proc)\//i.test(command) && /[><]/.test(command)) return RISK_BUCKETS.SYSTEM;
|
|
98
|
+
}
|
|
99
|
+
if (targetPath && /(\/etc\/|\/boot\/|\/sys\/|\/proc\/)/i.test(targetPath)) {
|
|
100
|
+
return RISK_BUCKETS.SYSTEM;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. EGRESS check
|
|
104
|
+
const netTools = new Set(DEFAULT_NETWORK_TOOLS);
|
|
105
|
+
if (netTools.has(toolName)) {
|
|
106
|
+
const url = (args && (args.url || args.Url || args.uri)) || "";
|
|
107
|
+
if (url && (isLoopbackHost(url) || isLoopbackUrl(url))) {
|
|
108
|
+
return RISK_BUCKETS.WORKSPACE;
|
|
109
|
+
}
|
|
110
|
+
return RISK_BUCKETS.EGRESS;
|
|
111
|
+
}
|
|
112
|
+
if (command) {
|
|
113
|
+
if (/git\s+push\b/i.test(command)) return RISK_BUCKETS.EGRESS;
|
|
114
|
+
const firstWord = command.split(/\s+/)[0];
|
|
115
|
+
if (NETWORK_CMDS.has(firstWord)) {
|
|
116
|
+
if (/\b(localhost|127\.0\.0\.1|::1|0\.0\.0\.0)\b/i.test(command)) {
|
|
117
|
+
return RISK_BUCKETS.WORKSPACE;
|
|
118
|
+
}
|
|
119
|
+
return RISK_BUCKETS.EGRESS;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. READ_ONLY check
|
|
124
|
+
const readTools = new Set(DEFAULT_READ_ONLY_TOOLS);
|
|
125
|
+
if (readTools.has(toolName)) {
|
|
126
|
+
return RISK_BUCKETS.READ_ONLY;
|
|
127
|
+
}
|
|
128
|
+
if (command) {
|
|
129
|
+
const normalizedCmd = command.split(/\s+/).slice(0, 2).join(" ");
|
|
130
|
+
const singleCmd = command.split(/\s+/)[0];
|
|
131
|
+
if (READ_ONLY_CMDS.has(normalizedCmd) || READ_ONLY_CMDS.has(singleCmd)) {
|
|
132
|
+
if (!/[><|&;]/.test(command)) {
|
|
133
|
+
return RISK_BUCKETS.READ_ONLY;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 5. WORKSPACE (default for dev tasks)
|
|
139
|
+
return RISK_BUCKETS.WORKSPACE;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Extracts multidimensional risk tags for an execution (#70).
|
|
144
|
+
*
|
|
145
|
+
* @param {any} execution
|
|
146
|
+
* @param {any} [context]
|
|
147
|
+
* @returns {string[]} Sorted array of unique tag strings
|
|
148
|
+
*/
|
|
149
|
+
export function extractRiskTags(execution, context = {}) {
|
|
150
|
+
const tags = new Set();
|
|
151
|
+
const toolName = String((execution && execution.name) || "").trim();
|
|
152
|
+
const args = execution && execution.arguments;
|
|
153
|
+
const command = (args && typeof args === "object" && typeof args.command === "string")
|
|
154
|
+
? args.command.trim()
|
|
155
|
+
: (typeof args === "string" ? args.trim() : "");
|
|
156
|
+
const targetPath = extractFilePath(args);
|
|
157
|
+
|
|
158
|
+
// File mutation
|
|
159
|
+
const writeTools = new Set(DEFAULT_FILE_WRITE_TOOLS);
|
|
160
|
+
if (
|
|
161
|
+
writeTools.has(toolName) ||
|
|
162
|
+
toolName === "write_file" ||
|
|
163
|
+
toolName === "replace_file_content" ||
|
|
164
|
+
/\b(write|edit)\b/i.test(toolName) ||
|
|
165
|
+
/[><]/.test(command) ||
|
|
166
|
+
/\b(rm|mv|cp|touch|mkdir|rmdir)\b/i.test(command)
|
|
167
|
+
) {
|
|
168
|
+
tags.add(RISK_TAGS.FILE_MUTATION);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Network
|
|
172
|
+
const netTools = new Set(DEFAULT_NETWORK_TOOLS);
|
|
173
|
+
if (netTools.has(toolName) || /git\s+push\b/i.test(command) || /\b(curl|wget|fetch|nc|ssh|ping)\b/i.test(command)) {
|
|
174
|
+
tags.add(RISK_TAGS.NETWORK);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Git
|
|
178
|
+
if (/^git\b/i.test(command) || toolName.includes("git")) {
|
|
179
|
+
tags.add(RISK_TAGS.GIT);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Credentials / Secrets
|
|
183
|
+
if (
|
|
184
|
+
(targetPath && (isProtectedConfigPath(targetPath) || SECRET_PATTERNS.some((p) => p.test(targetPath)))) ||
|
|
185
|
+
/\b(printenv|export|env)\b(?!\s*=[a-zA-Z0-9_.-]+)/i.test(command) ||
|
|
186
|
+
SECRET_PATTERNS.some((p) => p.test(command)) ||
|
|
187
|
+
/\b(Bearer|token|secret|password|api[_-]?key)\b/i.test(command)
|
|
188
|
+
) {
|
|
189
|
+
tags.add(RISK_TAGS.CREDENTIALS);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Package manager
|
|
193
|
+
if (command) {
|
|
194
|
+
const firstWord = command.split(/\s+/)[0].replace(/^sudo\s+/, "");
|
|
195
|
+
if (SYSTEM_PKG_MANAGERS.has(firstWord) || DEV_PKG_MANAGERS.has(firstWord)) {
|
|
196
|
+
tags.add(RISK_TAGS.PACKAGE_MANAGER);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Privilege escalation / System mutation
|
|
201
|
+
if (command) {
|
|
202
|
+
if (/^(sudo|su|doas)\b/i.test(command) || /\bchmod\s+(-[a-zA-Z]*\s+)?777\b/i.test(command)) {
|
|
203
|
+
tags.add(RISK_TAGS.PRIVILEGE_ESCALATION);
|
|
204
|
+
}
|
|
205
|
+
const firstWord = command.split(/\s+/)[0].replace(/^sudo\s+/, "");
|
|
206
|
+
if (SYSTEM_PKG_MANAGERS.has(firstWord) || SYSTEM_SERVICES.has(firstWord) || /\b(systemctl|service|reboot|shutdown)\b/i.test(command)) {
|
|
207
|
+
tags.add(RISK_TAGS.SYSTEM_MUTATION);
|
|
208
|
+
}
|
|
209
|
+
if (/\b(rm\s+-[a-zA-Z]*[rf]|mkfs|shred|wipefs|git\s+clean\s+-[a-zA-Z]*f|git\s+reset\s+--hard)\b/i.test(command)) {
|
|
210
|
+
tags.add(RISK_TAGS.DESTRUCTIVE);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Read only
|
|
215
|
+
const readTools = new Set(DEFAULT_READ_ONLY_TOOLS);
|
|
216
|
+
if (readTools.has(toolName) || (command && !tags.has(RISK_TAGS.FILE_MUTATION) && !tags.has(RISK_TAGS.DESTRUCTIVE) && !/[><|&;]/.test(command))) {
|
|
217
|
+
const singleCmd = command ? command.split(/\s+/)[0] : "";
|
|
218
|
+
if (READ_ONLY_CMDS.has(singleCmd) || readTools.has(toolName)) {
|
|
219
|
+
tags.add(RISK_TAGS.READ_ONLY);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return Array.from(tags).sort();
|
|
224
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-approval-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|