@goodandready/dsh-approval-gate 0.1.18 → 0.1.20
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 +16 -0
- package/lib/agents-parser.js +183 -0
- package/lib/artifacts.js +162 -0
- package/lib/classifier.js +150 -20
- package/lib/compat.js +124 -0
- package/lib/engine.js +125 -44
- package/lib/index.js +38 -34
- package/lib/latency.js +137 -0
- package/lib/messages.js +36 -1
- package/lib/risk.js +224 -0
- package/lib/trash.js +119 -0
- package/lib/version-guard.js +177 -0
- package/package.json +55 -55
package/lib/compat.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// lib/compat.js
|
|
2
|
+
// Host compatibility checks and polyfills across DeepSeek Harness minor versions (#106).
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detects available runtime capabilities and services in the Cordis host context (#106).
|
|
6
|
+
*
|
|
7
|
+
* @param {any} ctx - Cordis / DSH host context
|
|
8
|
+
* @returns {{
|
|
9
|
+
* hasContext: boolean,
|
|
10
|
+
* hasToolsService: boolean,
|
|
11
|
+
* hasPreExecuteHook: boolean,
|
|
12
|
+
* hasProvideApi: boolean,
|
|
13
|
+
* hasLocaleService: boolean,
|
|
14
|
+
* harnessVersion: string | null
|
|
15
|
+
* }}
|
|
16
|
+
*/
|
|
17
|
+
export function detectHarnessCapabilities(ctx) {
|
|
18
|
+
if (!ctx || typeof ctx !== "object") {
|
|
19
|
+
return {
|
|
20
|
+
hasContext: false,
|
|
21
|
+
hasToolsService: false,
|
|
22
|
+
hasPreExecuteHook: false,
|
|
23
|
+
hasProvideApi: false,
|
|
24
|
+
hasLocaleService: false,
|
|
25
|
+
harnessVersion: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const getService = (name) => {
|
|
30
|
+
try {
|
|
31
|
+
if (typeof ctx.get === "function") return ctx.get(name);
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
return ctx[name];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const tools = getService("tools");
|
|
39
|
+
const locale = getService("locale");
|
|
40
|
+
|
|
41
|
+
const harnessVersion =
|
|
42
|
+
(typeof ctx.version === "string" && ctx.version) ||
|
|
43
|
+
(typeof process.env.DSH_VERSION === "string" && process.env.DSH_VERSION) ||
|
|
44
|
+
null;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
hasContext: true,
|
|
48
|
+
hasToolsService: Boolean(tools),
|
|
49
|
+
hasPreExecuteHook: typeof ctx.on === "function",
|
|
50
|
+
hasProvideApi: typeof ctx.provide === "function",
|
|
51
|
+
hasLocaleService: Boolean(locale && typeof locale.register === "function"),
|
|
52
|
+
harnessVersion,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Asserts host compatibility and identifies missing optional or required features (#106).
|
|
58
|
+
*
|
|
59
|
+
* @param {any} ctx
|
|
60
|
+
* @param {{ requireTools?: boolean }} [options]
|
|
61
|
+
* @returns {{ compatible: boolean, issues: string[], capabilities: ReturnType<typeof detectHarnessCapabilities> }}
|
|
62
|
+
*/
|
|
63
|
+
export function assertHarnessCompatibility(ctx, options = {}) {
|
|
64
|
+
const caps = detectHarnessCapabilities(ctx);
|
|
65
|
+
const issues = [];
|
|
66
|
+
|
|
67
|
+
if (!caps.hasContext) {
|
|
68
|
+
issues.push("Invalid or missing Cordis context object");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.requireTools && !caps.hasToolsService) {
|
|
72
|
+
issues.push("Missing tools service in host context");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
compatible: issues.length === 0,
|
|
77
|
+
issues,
|
|
78
|
+
capabilities: caps,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Safely provides a service to the host context with backward-compatible polyfill (#106).
|
|
84
|
+
*
|
|
85
|
+
* @param {any} ctx
|
|
86
|
+
* @param {string} serviceName
|
|
87
|
+
* @param {any} serviceInstance
|
|
88
|
+
* @returns {boolean} True if successfully registered
|
|
89
|
+
*/
|
|
90
|
+
export function provideServicePolyfill(ctx, serviceName, serviceInstance) {
|
|
91
|
+
if (!ctx || typeof ctx !== "object" || !serviceName) return false;
|
|
92
|
+
|
|
93
|
+
if (typeof ctx.provide === "function") {
|
|
94
|
+
try {
|
|
95
|
+
ctx.provide(serviceName, serviceInstance);
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
// fallback to property assignment below
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ctx[serviceName] = serviceInstance;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Safely attaches an event hook to the host context with error boundary (#106).
|
|
108
|
+
*
|
|
109
|
+
* @param {any} ctx
|
|
110
|
+
* @param {string} event
|
|
111
|
+
* @param {(...args: any[]) => any} handler
|
|
112
|
+
* @returns {(() => void) | null} Unsubscribe dispose function or null
|
|
113
|
+
*/
|
|
114
|
+
export function registerHookPolyfill(ctx, event, handler) {
|
|
115
|
+
if (!ctx || typeof ctx.on !== "function" || typeof handler !== "function") {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
return ctx.on(event, handler);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/lib/engine.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
// lib/engine.js
|
|
2
|
-
// 5-tier Security Decision Engine for dsh-approval-gate.
|
|
3
|
-
// Waterfall:
|
|
4
|
-
// P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion + fail-closed audit)
|
|
5
|
-
// P1: Session Grants & Declarative Allow (scoped temporary allowances and whitelists)
|
|
6
|
-
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
|
-
// P3: Optional Semantic Classifier
|
|
8
|
-
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask / lockedAsk)
|
|
2
|
+
// 5-tier Security Decision Engine for dsh-approval-gate (P0-P4 Waterfall).
|
|
9
3
|
|
|
10
4
|
import { BandsEngine } from "./bands.js";
|
|
11
5
|
import { CircuitBreaker } from "./breaker.js";
|
|
@@ -20,6 +14,12 @@ import { BlockCounters } from "./counters.js";
|
|
|
20
14
|
import { ContractGuard } from "./contract-guard.js";
|
|
21
15
|
import { OverengineeringGuard } from "./overengineering.js";
|
|
22
16
|
import { extractFilePath, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
|
|
17
|
+
import { categorizeRiskBucket, extractRiskTags } from "./risk.js";
|
|
18
|
+
import { ClassifierLatencyTracker } from "./latency.js";
|
|
19
|
+
import { ArtifactRegistry } from "./artifacts.js";
|
|
20
|
+
import { isRmCommand, extractRmTargets, suggestTrashAlternative } from "./trash.js";
|
|
21
|
+
import { VersionGuard } from "./version-guard.js";
|
|
22
|
+
import { loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
|
|
@@ -71,26 +71,7 @@ export function isCriticalOperation(toolName, command, execution) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export class SecurityEngine {
|
|
74
|
-
/**
|
|
75
|
-
* @param {{
|
|
76
|
-
* bands?: BandsEngine,
|
|
77
|
-
* breaker?: CircuitBreaker,
|
|
78
|
-
* cache?: VerdictCache,
|
|
79
|
-
* retryGuard?: RetryExhaustionGuard,
|
|
80
|
-
* ledger?: QualityLedger,
|
|
81
|
-
* auditLogger?: AuditLogger,
|
|
82
|
-
* declarativeRules?: DeclarativeRulesEngine,
|
|
83
|
-
* counters?: BlockCounters,
|
|
84
|
-
* auditFailClosed?: boolean,
|
|
85
|
-
* timeoutPolicy?: "strict" | "permissive",
|
|
86
|
-
* inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
|
|
87
|
-
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
88
|
-
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
|
|
89
|
-
* dryRun?: boolean,
|
|
90
|
-
* shadowMode?: boolean,
|
|
91
|
-
* defaultVerdict?: "allow" | "ask"
|
|
92
|
-
* }} [options]
|
|
93
|
-
*/
|
|
74
|
+
/** @param {Record<string, any>} [options] */
|
|
94
75
|
constructor(options = {}) {
|
|
95
76
|
this.bands = options.bands || new BandsEngine(options);
|
|
96
77
|
this.breaker = options.breaker || new CircuitBreaker(options);
|
|
@@ -123,6 +104,16 @@ export class SecurityEngine {
|
|
|
123
104
|
: (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined
|
|
124
105
|
? new OverengineeringGuard(options)
|
|
125
106
|
: null);
|
|
107
|
+
this.latencyTracker = options.latencyTracker || new ClassifierLatencyTracker(options);
|
|
108
|
+
this.artifactRegistry = options.artifactRegistry !== undefined
|
|
109
|
+
? options.artifactRegistry
|
|
110
|
+
: new ArtifactRegistry({ workspaceDir: this.inspectConfig?.workspaceDir });
|
|
111
|
+
this.versionGuard = options.versionGuard !== undefined
|
|
112
|
+
? options.versionGuard
|
|
113
|
+
: new VersionGuard({ workspaceDir: this.inspectConfig?.workspaceDir });
|
|
114
|
+
this.agentsRules = options.agentsRules !== undefined
|
|
115
|
+
? options.agentsRules
|
|
116
|
+
: (loadAgentsRules(this.inspectConfig?.workspaceDir)?.rules || null);
|
|
126
117
|
}
|
|
127
118
|
|
|
128
119
|
/**
|
|
@@ -152,9 +143,9 @@ export class SecurityEngine {
|
|
|
152
143
|
if (this.auditLogger) this.auditLogger.appendAuditLine({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
|
|
153
144
|
|
|
154
145
|
if (this.dryRun) {
|
|
155
|
-
return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
146
|
+
return this._enrichDecision({ verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit }, execution, context);
|
|
156
147
|
}
|
|
157
|
-
return exhDecision;
|
|
148
|
+
return this._enrichDecision(exhDecision, execution, context);
|
|
158
149
|
}
|
|
159
150
|
|
|
160
151
|
// Check Verdict Cache (#63, #119)
|
|
@@ -163,7 +154,7 @@ export class SecurityEngine {
|
|
|
163
154
|
const cached = this.cache.get(cacheKey);
|
|
164
155
|
if (cached) {
|
|
165
156
|
if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
|
|
166
|
-
return {
|
|
157
|
+
return this._enrichDecision({
|
|
167
158
|
verdict: "allow",
|
|
168
159
|
stage: cached.stage,
|
|
169
160
|
dryRun: true,
|
|
@@ -172,14 +163,15 @@ export class SecurityEngine {
|
|
|
172
163
|
detail: cached.detail,
|
|
173
164
|
hit: cached.hit,
|
|
174
165
|
cached: true,
|
|
175
|
-
};
|
|
166
|
+
}, execution, context);
|
|
176
167
|
}
|
|
177
168
|
if (this.counters) this.counters.record(cached, execution);
|
|
178
|
-
return cached;
|
|
169
|
+
return this._enrichDecision(cached, execution, context);
|
|
179
170
|
}
|
|
180
171
|
}
|
|
181
172
|
|
|
182
173
|
let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
|
|
174
|
+
this._enrichDecision(decision, execution, context);
|
|
183
175
|
|
|
184
176
|
// Post-decision hooks: ledger, retryGuard, cache
|
|
185
177
|
if (decision.verdict === "deny") {
|
|
@@ -190,6 +182,10 @@ export class SecurityEngine {
|
|
|
190
182
|
if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
|
|
191
183
|
if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
192
184
|
if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
|
|
185
|
+
if (this.artifactRegistry) {
|
|
186
|
+
const targetPath = extractFilePath(execution && execution.arguments);
|
|
187
|
+
if (targetPath) this.artifactRegistry.recordArtifact(sessionId, targetPath);
|
|
188
|
+
}
|
|
193
189
|
} else if (decision.verdict === "ask") {
|
|
194
190
|
if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
195
191
|
}
|
|
@@ -212,6 +208,7 @@ export class SecurityEngine {
|
|
|
212
208
|
hit: auditDenyHit,
|
|
213
209
|
auditFailed: true,
|
|
214
210
|
};
|
|
211
|
+
this._enrichDecision(decision, execution, context);
|
|
215
212
|
}
|
|
216
213
|
}
|
|
217
214
|
|
|
@@ -222,7 +219,7 @@ export class SecurityEngine {
|
|
|
222
219
|
|
|
223
220
|
// Shadow Mode (#128)
|
|
224
221
|
if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
|
|
225
|
-
return {
|
|
222
|
+
return this._enrichDecision({
|
|
226
223
|
verdict: "allow",
|
|
227
224
|
stage: decision.stage,
|
|
228
225
|
dryRun: true,
|
|
@@ -231,7 +228,7 @@ export class SecurityEngine {
|
|
|
231
228
|
detail: decision.detail,
|
|
232
229
|
hit: decision.hit,
|
|
233
230
|
lockedAsk: decision.lockedAsk,
|
|
234
|
-
};
|
|
231
|
+
}, execution, context);
|
|
235
232
|
}
|
|
236
233
|
|
|
237
234
|
return decision;
|
|
@@ -244,12 +241,12 @@ export class SecurityEngine {
|
|
|
244
241
|
hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
|
|
245
242
|
};
|
|
246
243
|
if (this.counters) this.counters.record(errDecision, execution);
|
|
247
|
-
return errDecision;
|
|
244
|
+
return this._enrichDecision(errDecision, execution, context);
|
|
248
245
|
}
|
|
249
246
|
}
|
|
250
247
|
|
|
251
248
|
/**
|
|
252
|
-
* Asynchronous evaluation supporting async classifiers (#129, #118).
|
|
249
|
+
* Asynchronous evaluation supporting async classifiers and latency SLA tracking (#129, #118, #57).
|
|
253
250
|
*
|
|
254
251
|
* @param {{ name: string, arguments?: any }} execution
|
|
255
252
|
* @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context]
|
|
@@ -259,15 +256,31 @@ export class SecurityEngine {
|
|
|
259
256
|
if (this.classifier) {
|
|
260
257
|
let resolvedVerdict = null;
|
|
261
258
|
let caughtError = null;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
259
|
+
|
|
260
|
+
if (this.latencyTracker && this.latencyTracker.isDegraded()) {
|
|
261
|
+
resolvedVerdict = {
|
|
262
|
+
verdict: "ask",
|
|
263
|
+
reason: "classifier-sla-fallback",
|
|
264
|
+
detail: `Classifier latency p95 (${this.latencyTracker.getP95()}ms) exceeded SLA (${this.latencyTracker.slaMs}ms) (#57)`,
|
|
265
|
+
};
|
|
266
|
+
} else {
|
|
267
|
+
const start = Date.now();
|
|
268
|
+
try {
|
|
269
|
+
if (typeof this.classifier.classify === "function") {
|
|
270
|
+
resolvedVerdict = await this.classifier.classify(execution, context);
|
|
271
|
+
} else if (typeof this.classifier === "function") {
|
|
272
|
+
const res = this.classifier(execution, context);
|
|
273
|
+
resolvedVerdict = res && typeof res.then === "function" ? await res : res;
|
|
274
|
+
}
|
|
275
|
+
if (this.latencyTracker) {
|
|
276
|
+
this.latencyTracker.recordSample(Date.now() - start);
|
|
277
|
+
}
|
|
278
|
+
} catch (err) {
|
|
279
|
+
caughtError = err;
|
|
280
|
+
if (this.latencyTracker) {
|
|
281
|
+
this.latencyTracker.recordSample(Date.now() - start);
|
|
282
|
+
}
|
|
268
283
|
}
|
|
269
|
-
} catch (err) {
|
|
270
|
-
caughtError = err;
|
|
271
284
|
}
|
|
272
285
|
|
|
273
286
|
const originalClassifier = this.classifier;
|
|
@@ -320,6 +333,16 @@ export class SecurityEngine {
|
|
|
320
333
|
}
|
|
321
334
|
}
|
|
322
335
|
|
|
336
|
+
// AGENTS.md rules (#91)
|
|
337
|
+
if (this.agentsRules) {
|
|
338
|
+
const ag = evaluateAgentsRules(this.agentsRules, execution, extractFilePath(execution && execution.arguments));
|
|
339
|
+
if (ag.match) {
|
|
340
|
+
this.breaker.countDeny(sessionId);
|
|
341
|
+
const agHit = { deny: true, reason: ag.reason, detail: ag.rule?.text, snippet: command || toolName };
|
|
342
|
+
return { verdict: "deny", stage: "P0", reason: ag.reason, detail: ag.rule?.text, hit: agHit };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
323
346
|
// Inspect execution for hard denials and syntax checks
|
|
324
347
|
const hit = inspectExecution(execution, this.inspectConfig);
|
|
325
348
|
if (hit.deny) {
|
|
@@ -363,6 +386,31 @@ export class SecurityEngine {
|
|
|
363
386
|
}
|
|
364
387
|
}
|
|
365
388
|
|
|
389
|
+
// VersionGuard: major version bump control (#95)
|
|
390
|
+
if (this.versionGuard && typeof toolName === "string" && fileTools.has(toolName)) {
|
|
391
|
+
const targetPath = extractFilePath(execution ? execution.arguments : undefined);
|
|
392
|
+
const content = execution?.arguments?.content || execution?.arguments?.code || execution?.arguments?.CodeContent || execution?.arguments?.text;
|
|
393
|
+
if (targetPath && content) {
|
|
394
|
+
const vg = this.versionGuard.inspectWrite(targetPath, content);
|
|
395
|
+
if (vg.majorBump) {
|
|
396
|
+
const vgHit = { ask: true, reason: "major-version-bump", detail: vg.detail, snippet: targetPath };
|
|
397
|
+
const res = { verdict: "ask", stage: "P4", reason: "major-version-bump", detail: vg.detail, hit: vgHit };
|
|
398
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
399
|
+
return res;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// P1: Session Artifact Cleanup Whitelist (#100)
|
|
405
|
+
if (command && isRmCommand(command)) {
|
|
406
|
+
const targets = extractRmTargets(command);
|
|
407
|
+
if (targets.length > 0 && this.artifactRegistry && this.artifactRegistry.areAllSessionArtifacts(sessionId, targets)) {
|
|
408
|
+
this.breaker.resetConsecutive(sessionId);
|
|
409
|
+
const artHit = { allow: true, reason: "session-artifact-cleanup", snippet: command };
|
|
410
|
+
return { verdict: "allow", stage: "P1", reason: "session-artifact-cleanup", hit: artHit };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
366
414
|
// P1: Session Grants
|
|
367
415
|
if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
|
|
368
416
|
const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
|
|
@@ -509,4 +557,37 @@ export class SecurityEngine {
|
|
|
509
557
|
if (this.cache) this.cache.clearSession(sessionId);
|
|
510
558
|
return this.breaker.resume(sessionId, reason);
|
|
511
559
|
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Enriches decision with risk bucket (#58) and operational risk tags (#70).
|
|
563
|
+
* @private
|
|
564
|
+
*/
|
|
565
|
+
_enrichDecision(decision, execution, context) {
|
|
566
|
+
if (!decision) return decision;
|
|
567
|
+
if (!decision.riskBucket) decision.riskBucket = categorizeRiskBucket(execution, context);
|
|
568
|
+
if (!decision.riskTags || !Array.isArray(decision.riskTags)) decision.riskTags = extractRiskTags(execution, context);
|
|
569
|
+
const command = execution && execution.arguments && execution.arguments.command;
|
|
570
|
+
if (command && isRmCommand(command)) {
|
|
571
|
+
decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
|
|
572
|
+
}
|
|
573
|
+
return decision;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
getArtifacts(sessionId) {
|
|
577
|
+
return this.artifactRegistry ? this.artifactRegistry.getArtifacts(sessionId) : [];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
clearArtifacts(sessionId) {
|
|
581
|
+
if (this.artifactRegistry) this.artifactRegistry.clearSession(sessionId);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
getLatencyMetrics() {
|
|
585
|
+
return this.latencyTracker ? this.latencyTracker.getMetrics() : null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
resetLatencyTracker() {
|
|
589
|
+
if (this.latencyTracker) this.latencyTracker.reset();
|
|
590
|
+
}
|
|
512
591
|
}
|
|
592
|
+
|
|
593
|
+
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,8 @@ 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";
|
|
7
|
+
import { assertHarnessCompatibility, provideServicePolyfill } from "./compat.js";
|
|
6
8
|
import Schema from "@deepseek-ai/schemastery";
|
|
7
9
|
|
|
8
10
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -236,8 +238,14 @@ export const Config = Schema.object({
|
|
|
236
238
|
isolateTempRoots: Schema.boolean().default(true).volatile(),
|
|
237
239
|
/** Maximum allowed created files per session to prevent overengineering (#89). */
|
|
238
240
|
maxFilesPerSession: Schema.number().default(10).volatile(),
|
|
241
|
+
/** Maximum latency in ms before classifier degrades to deterministic rules (#57). */
|
|
242
|
+
classifierSlaMs: Schema.number().default(2000).volatile(),
|
|
239
243
|
/** Task contract boundary rules (#87). */
|
|
240
244
|
taskContract: Schema.any().volatile(),
|
|
245
|
+
/** Enforce project rules and constraints parsed from AGENTS.md (#91). */
|
|
246
|
+
agentsRules: Schema.boolean().default(true).volatile(),
|
|
247
|
+
/** Enforce human confirmation on breaking major dependency bumps in package.json (#95). */
|
|
248
|
+
versionGuard: Schema.boolean().default(true).volatile(),
|
|
241
249
|
/** Security engine instance (internal/testing). */
|
|
242
250
|
engine: Schema.any().hidden(),
|
|
243
251
|
});
|
|
@@ -248,6 +256,7 @@ function logBlocked(ctx, hit, who) {
|
|
|
248
256
|
}
|
|
249
257
|
|
|
250
258
|
export function apply(ctx, config) {
|
|
259
|
+
const compat = assertHarnessCompatibility(ctx);
|
|
251
260
|
const cfg = plainConfig(Config(plainConfig(config) || {}) || {});
|
|
252
261
|
const toolName = cfg.toolName || DEFAULT_BASH_TOOL;
|
|
253
262
|
const fileWriteTools = cfg.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS.slice();
|
|
@@ -301,6 +310,9 @@ export function apply(ctx, config) {
|
|
|
301
310
|
bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
|
|
302
311
|
classifier,
|
|
303
312
|
timeoutPolicy: cfg.classifierTimeoutPolicy || "strict",
|
|
313
|
+
latencyTracker: new ClassifierLatencyTracker({
|
|
314
|
+
slaMs: cfg.classifierSlaMs !== undefined ? cfg.classifierSlaMs : 2000,
|
|
315
|
+
}),
|
|
304
316
|
defaultVerdict: cfg.defaultVerdict || "allow",
|
|
305
317
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
306
318
|
auditFailClosed: Boolean(cfg.auditFailClosed),
|
|
@@ -326,6 +338,10 @@ export function apply(ctx, config) {
|
|
|
326
338
|
getCounters: () => engine.getCounters(),
|
|
327
339
|
resetCounters: () => engine.resetCounters(),
|
|
328
340
|
getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
|
|
341
|
+
getLatencyMetrics: () => (engine.getLatencyMetrics ? engine.getLatencyMetrics() : null),
|
|
342
|
+
resetLatencyTracker: () => (engine.resetLatencyTracker ? engine.resetLatencyTracker() : undefined),
|
|
343
|
+
getArtifacts: (sessionId) => (engine.getArtifacts ? engine.getArtifacts(sessionId) : []),
|
|
344
|
+
clearArtifacts: (sessionId) => (engine.clearArtifacts ? engine.clearArtifacts(sessionId) : undefined),
|
|
329
345
|
getSnapshot: () => ({
|
|
330
346
|
active: true,
|
|
331
347
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
@@ -336,15 +352,11 @@ export function apply(ctx, config) {
|
|
|
336
352
|
rulesCount: engine.declarativeRules ? engine.declarativeRules.count() : 0,
|
|
337
353
|
cacheStats: engine.cache ? engine.cache.stats() : null,
|
|
338
354
|
breakerState: engine.breaker ? engine.breaker.getState() : null,
|
|
355
|
+
latencyMetrics: engine.getLatencyMetrics ? engine.getLatencyMetrics() : null,
|
|
356
|
+
harnessCompat: compat.capabilities,
|
|
339
357
|
}),
|
|
340
358
|
};
|
|
341
|
-
|
|
342
|
-
if (typeof ctx.provide === "function") {
|
|
343
|
-
ctx.provide("approvalGate", gateService);
|
|
344
|
-
} else {
|
|
345
|
-
ctx.approvalGate = gateService;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
359
|
+
provideServicePolyfill(ctx, "approvalGate", gateService);
|
|
348
360
|
|
|
349
361
|
const hasPreExecute = ctx && typeof ctx.on === "function";
|
|
350
362
|
if (hasPreExecute) {
|
|
@@ -536,42 +548,34 @@ export {
|
|
|
536
548
|
DEFAULT_RETAIN_LINES,
|
|
537
549
|
auditRotateContent,
|
|
538
550
|
} from "./audit-log.js";
|
|
539
|
-
export {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
} from "./
|
|
543
|
-
export {
|
|
544
|
-
|
|
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";
|
|
560
|
-
export {
|
|
561
|
-
DeclarativeRulesEngine,
|
|
562
|
-
BUILTIN_RULES_FILE,
|
|
563
|
-
WORKSPACE_RULES_REL,
|
|
564
|
-
USER_RULES_REL,
|
|
565
|
-
} from "./rules-loader.js";
|
|
551
|
+
export { BlockCounters, categorizeReason } from "./counters.js";
|
|
552
|
+
export { isCriticalOperation } from "./engine.js";
|
|
553
|
+
export { parseYaml, parseScalar, stripComment } from "./yaml-parser.js";
|
|
554
|
+
export { matchConditionValue, evaluateWhen } from "./when-matcher.js";
|
|
555
|
+
export { HotwordsMatcher, DEFAULT_HOTWORDS, extractWords } from "./hotwords.js";
|
|
556
|
+
export { DeclarativeRulesEngine, BUILTIN_RULES_FILE, WORKSPACE_RULES_REL, USER_RULES_REL } from "./rules-loader.js";
|
|
566
557
|
export {
|
|
567
558
|
normalizeChatCompletionsUrl,
|
|
568
559
|
withLlmRetry,
|
|
569
560
|
sanitizeClassifierInput,
|
|
570
561
|
truncateClassifierContext,
|
|
571
562
|
isLowRiskExecution,
|
|
563
|
+
isShapeRejectError,
|
|
564
|
+
extractJsonPayload,
|
|
565
|
+
ClassifierVerdictSchema,
|
|
566
|
+
validateClassifierResponse,
|
|
572
567
|
LlmClassifier,
|
|
573
568
|
} from "./classifier.js";
|
|
574
569
|
export { ContractGuard } from "./contract-guard.js";
|
|
575
570
|
export { OverengineeringGuard, DEFAULT_MAX_FILES_PER_SESSION } from "./overengineering.js";
|
|
576
571
|
export { resolveTempRoot, isGlobalTempPath, checkTempIsolation } from "./paths.js";
|
|
577
572
|
export { computePayloadSize, scanPromptInjection } from "./inspect.js";
|
|
573
|
+
export { RISK_BUCKETS, RISK_TAGS, categorizeRiskBucket, extractRiskTags } from "./risk.js";
|
|
574
|
+
export { ClassifierLatencyTracker, DEFAULT_CLASSIFIER_SLA_MS } from "./latency.js";
|
|
575
|
+
export { resolveTrashDir, isRmCommand, extractRmTargets, buildTrashCommand, suggestTrashAlternative } from "./trash.js";
|
|
576
|
+
export { ArtifactRegistry, normalizeArtifactPath } from "./artifacts.js";
|
|
577
|
+
export { parseAgentsSecurityRules, loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
|
|
578
|
+
export { detectHarnessCapabilities, assertHarnessCompatibility, provideServicePolyfill, registerHookPolyfill } from "./compat.js";
|
|
579
|
+
export { parseSemver, isMajorBump, inspectPackageJsonDiff, VersionGuard } from "./version-guard.js";
|
|
580
|
+
|
|
581
|
+
|
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
|
+
}
|