@goodandready/dsh-approval-gate 0.1.17 → 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/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,16 @@ 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",
72
+ askPayloadTooLarge: "Command arguments exceed maximum payload size limit; review before allowing",
73
+ ruleReviewerInjectionAttempt: "Prompt instruction injection detected in tool arguments",
74
+ askContractViolation: "Operation falls outside task contract boundaries; review before allowing",
75
+ ruleContractViolation: "Execution violated task contract boundaries",
76
+ askUnisolatedTempPath: "Target path uses global /tmp instead of isolated project temp directory; review before allowing",
77
+ ruleUnisolatedTempPath: "Target path uses unisolated global temp directory",
78
+ askOverengineeringLimit: "Session file creation limit exceeded; potential overengineering requires review before allowing",
79
+ ruleOverengineeringLimit: "Session exceeded maximum allowed created files limit",
70
80
  hintRecursiveRm: "Specify exact target files instead of recursive deletion",
71
81
  hintPathTraversal: "Keep operations strictly within the project directory",
72
82
  hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
@@ -83,6 +93,13 @@ export const MESSAGES = Object.freeze({
83
93
  hintFailClosedAudit: "Ensure the audit log directory is writable and disk space is available",
84
94
  hintLockedAskUnattended: "Switch to an interactive session or request explicit human confirmation",
85
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",
98
+ hintPayloadTooLarge: "Reduce input payload size or chunk data into smaller batches",
99
+ hintReviewerInjectionAttempt: "Remove adversarial prompt override directives from input",
100
+ hintContractViolation: "Confine operations to permitted task tools and project scope",
101
+ hintUnisolatedTempPath: "Use workspace-isolated temporary directory (<workspace>/.dsh/tmp)",
102
+ hintOverengineeringLimit: "Reuse existing files and avoid creating redundant abstractions or boilerplate",
86
103
  },
87
104
  zh: {
88
105
  blockedPrefix: "dsh-approval-gate 规则已阻止",
@@ -149,6 +166,16 @@ export const MESSAGES = Object.freeze({
149
166
  ruleClassifierTimeoutPermissive: "语义分类器超时;根据低风险容错策略放行",
150
167
  askClassifierTimeout: "语义分类器请求超时;需要人工审查",
151
168
  askClassifierError: "语义分类器发生异常;需要人工审查",
169
+ askClassifierSlaFallback: "语义分类器延迟超出 SLA 阈值;需要人工审查",
170
+ askMalformedClassifierResponse: "语义分类器返回非预期或未通过校验的判定;需要人工审查",
171
+ askPayloadTooLarge: "命令参数超过最大负载大小限制;请在允许前进行审查",
172
+ ruleReviewerInjectionAttempt: "在工具参数中检测到提示词指令注入尝试",
173
+ askContractViolation: "操作超出任务契约范围;请在允许前进行审查",
174
+ ruleContractViolation: "执行违反了任务契约边界",
175
+ askUnisolatedTempPath: "目标路径使用全局 /tmp 而非隔离的项目临时目录;请在允许前进行审查",
176
+ ruleUnisolatedTempPath: "目标路径使用未隔离的全局临时目录",
177
+ askOverengineeringLimit: "会话创建文件数超过限制;潜在的过度工程需要人工审查",
178
+ ruleOverengineeringLimit: "会话超出最大允许创建文件数限制",
152
179
  hintRecursiveRm: "请指定具体目标文件,避免递归删除",
153
180
  hintPathTraversal: "请将操作限制在项目根目录内",
154
181
  hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
@@ -165,6 +192,13 @@ export const MESSAGES = Object.freeze({
165
192
  hintFailClosedAudit: "请确保审计日志目录可写且磁盘空间充足",
166
193
  hintLockedAskUnattended: "请切换到交互式会话或请求人工显式确认",
167
194
  hintClassifierTimeout: "请检查外部 LLM 服务连通性或调整分类器超时容错策略",
195
+ hintClassifierSlaFallback: "请检查外部 LLM 分类器负载或调高 classifierSlaMs 阈值",
196
+ hintMalformedClassifierResponse: "请检查模型 JSON 输出格式或验证返回模式兼容性",
197
+ hintPayloadTooLarge: "减小输入参数大小或分批传输数据",
198
+ hintReviewerInjectionAttempt: "从输入中移除对抗性指令覆盖指令",
199
+ hintContractViolation: "将操作限制在允许的任务工具和项目范围内",
200
+ hintUnisolatedTempPath: "使用工作区隔离的临时目录 (<workspace>/.dsh/tmp)",
201
+ hintOverengineeringLimit: "复用现有文件,避免创建多余的抽象或样板代码",
168
202
  },
169
203
  });
170
204
 
@@ -230,6 +264,12 @@ const RULE_KEYS = Object.freeze({
230
264
  "classifier-timeout-strict": "ruleClassifierTimeoutStrict",
231
265
  "classifier-timeout-fallback-strict": "ruleClassifierTimeoutStrict",
232
266
  "classifier-timeout-fallback-permissive": "ruleClassifierTimeoutPermissive",
267
+ "classifier-sla-fallback": "askClassifierSlaFallback",
268
+ "malformed-classifier-response": "askMalformedClassifierResponse",
269
+ "reviewer-injection-attempt": "ruleReviewerInjectionAttempt",
270
+ "contract-violation": "ruleContractViolation",
271
+ "unisolated-temp-path": "ruleUnisolatedTempPath",
272
+ "overengineering-file-limit": "ruleOverengineeringLimit",
233
273
  });
234
274
 
235
275
  const HINT_KEYS = Object.freeze({
@@ -250,6 +290,13 @@ const HINT_KEYS = Object.freeze({
250
290
  "locked-ask-unattended-denied": "hintLockedAskUnattended",
251
291
  "classifier-timeout-strict": "hintClassifierTimeout",
252
292
  "classifier-timeout-fallback-strict": "hintClassifierTimeout",
293
+ "classifier-sla-fallback": "hintClassifierSlaFallback",
294
+ "malformed-classifier-response": "hintMalformedClassifierResponse",
295
+ "payload-too-large": "hintPayloadTooLarge",
296
+ "reviewer-injection-attempt": "hintReviewerInjectionAttempt",
297
+ "contract-violation": "hintContractViolation",
298
+ "unisolated-temp-path": "hintUnisolatedTempPath",
299
+ "overengineering-file-limit": "hintOverengineeringLimit",
253
300
  });
254
301
 
255
302
  const ASK_KEYS = Object.freeze({
@@ -268,6 +315,12 @@ const ASK_KEYS = Object.freeze({
268
315
  "classifier-timeout-strict": "askClassifierTimeout",
269
316
  "classifier-timeout-fallback-strict": "askClassifierTimeout",
270
317
  "classifier-error": "askClassifierError",
318
+ "classifier-sla-fallback": "askClassifierSlaFallback",
319
+ "malformed-classifier-response": "askMalformedClassifierResponse",
320
+ "payload-too-large": "askPayloadTooLarge",
321
+ "contract-violation": "askContractViolation",
322
+ "unisolated-temp-path": "askUnisolatedTempPath",
323
+ "overengineering-file-limit": "askOverengineeringLimit",
271
324
  });
272
325
 
273
326
  const DETAIL_KEYS = Object.freeze({
@@ -0,0 +1,97 @@
1
+ // lib/overengineering.js
2
+ // Overengineering guard limiting excessive file creation per session (#89).
3
+
4
+ export const DEFAULT_MAX_FILES_PER_SESSION = 10;
5
+
6
+ /**
7
+ * Tracks and limits file creation within agent sessions to prevent codebase bloat and overengineering (#89).
8
+ */
9
+ export class OverengineeringGuard {
10
+ /**
11
+ * @param {{ maxFilesPerSession?: number }} [options]
12
+ */
13
+ constructor(options = {}) {
14
+ this.maxFilesPerSession =
15
+ typeof options.maxFilesPerSession === "number" && options.maxFilesPerSession > 0
16
+ ? options.maxFilesPerSession
17
+ : (typeof options.maxCreatedFilesPerSession === "number" && options.maxCreatedFilesPerSession > 0
18
+ ? options.maxCreatedFilesPerSession
19
+ : DEFAULT_MAX_FILES_PER_SESSION);
20
+ this.maxCreatedFilesPerSession = this.maxFilesPerSession;
21
+ this._sessionFiles = new Map();
22
+ }
23
+
24
+ /**
25
+ * Gets or initializes the Set of files created in this session.
26
+ * @private
27
+ */
28
+ _getSessionSet(sessionId) {
29
+ const key = sessionId || "default";
30
+ if (!this._sessionFiles.has(key)) {
31
+ this._sessionFiles.set(key, new Set());
32
+ }
33
+ return this._sessionFiles.get(key);
34
+ }
35
+
36
+ /**
37
+ * Checks file creation against threshold and records new files.
38
+ *
39
+ * @param {string} sessionId
40
+ * @param {string} filePath
41
+ * @returns {{ allowed: boolean, reason?: string, count: number, limit: number, detail?: string }}
42
+ */
43
+ checkAndRecord(sessionId, filePath) {
44
+ if (!filePath || typeof filePath !== "string") {
45
+ return { allowed: true, count: this.getCount(sessionId), limit: this.maxFilesPerSession };
46
+ }
47
+
48
+ const cleanPath = filePath.trim().replace(/\\/g, "/");
49
+ const set = this._getSessionSet(sessionId);
50
+
51
+ // If file was already created/tracked in this session, modifying it is permitted
52
+ if (set.has(cleanPath)) {
53
+ return { allowed: true, count: set.size, limit: this.maxFilesPerSession };
54
+ }
55
+
56
+ // Check if adding this file exceeds limit
57
+ if (set.size >= this.maxCreatedFilesPerSession) {
58
+ return {
59
+ allowed: false,
60
+ reason: "overengineering-file-limit",
61
+ count: set.size + 1,
62
+ limit: this.maxCreatedFilesPerSession,
63
+ detail: `Created ${set.size + 1} files in session, exceeding limit of ${this.maxCreatedFilesPerSession} files (#89)`,
64
+ };
65
+ }
66
+
67
+ set.add(cleanPath);
68
+ return { allowed: true, count: set.size, limit: this.maxFilesPerSession };
69
+ }
70
+
71
+ /**
72
+ * Returns current count of created files in session.
73
+ * @param {string} sessionId
74
+ * @returns {number}
75
+ */
76
+ getCount(sessionId) {
77
+ const key = sessionId || "default";
78
+ const set = this._sessionFiles.get(key);
79
+ return set ? set.size : 0;
80
+ }
81
+
82
+ /**
83
+ * Clears session tracked files.
84
+ * @param {string} sessionId
85
+ */
86
+ reset(sessionId) {
87
+ const key = sessionId || "default";
88
+ this._sessionFiles.delete(key);
89
+ }
90
+
91
+ /**
92
+ * Clears all session tracking.
93
+ */
94
+ clear() {
95
+ this._sessionFiles.clear();
96
+ }
97
+ }