@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/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
- try {
263
- if (typeof this.classifier.classify === "function") {
264
- resolvedVerdict = await this.classifier.classify(execution, context);
265
- } else if (typeof this.classifier === "function") {
266
- const res = this.classifier(execution, context);
267
- resolvedVerdict = res && typeof res.then === "function" ? await res : res;
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
- if (ctx && typeof ctx === "object") {
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
- BlockCounters,
541
- categorizeReason,
542
- } from "./counters.js";
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";
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
+ }