@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/dispatcher.js CHANGED
@@ -1,208 +1,210 @@
1
- // lib/dispatcher.js
2
- // Command Dispatcher: architectural isolation of command parsing and syntactic dispatch
3
- // from the safety evaluation engine.
4
- // Resolves: #133, #65
5
-
6
- import { tokenize, splitSubcommands } from "./tokenizer.js";
7
- import { parseGitArgv, classifyGitOperation } from "./parsers/git.js";
8
- import { extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
9
- import { isLoopbackCommand, isLoopbackUrl } from "./loopback.js";
10
- import { resolveSafePath, isPathTraversal, isProtectedConfigPath } from "./paths.js";
11
-
12
- export const TOOL_KIND_SHELL = "shell";
13
- export const TOOL_KIND_FILE_WRITE = "file_write";
14
- export const TOOL_KIND_FILE_READ = "file_read";
15
- export const TOOL_KIND_NETWORK = "network";
16
- export const TOOL_KIND_GENERIC = "generic";
17
-
18
- export const DEFAULT_BASH_TOOL = "bash";
19
- export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
20
- "write",
21
- "edit",
22
- "Write",
23
- "Edit",
24
- "str_replace_editor",
25
- "str_replace",
26
- "apply_patch",
27
- ]);
28
-
29
- export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
30
- "read_file",
31
- "view_file",
32
- "read_url_content",
33
- "search_web",
34
- "list_dir",
35
- "glob",
36
- "grep",
37
- ]);
38
-
39
- export const DEFAULT_NETWORK_TOOLS = Object.freeze([
40
- "fetch",
41
- "web_fetch",
42
- "http_request",
43
- "read_url_content",
44
- ]);
45
-
46
- /**
47
- * Extracts target file path from various tool argument schemas.
48
- *
49
- * @param {any} args
50
- * @returns {string | null}
51
- */
52
- export function extractFilePath(args) {
53
- if (typeof args === "string") return args;
54
- if (!args || typeof args !== "object") return null;
55
- const keys = [
56
- "path",
57
- "file_path",
58
- "filePath",
59
- "file",
60
- "filename",
61
- "target",
62
- "to",
63
- "TargetFile",
64
- "AbsolutePath",
65
- ];
66
- for (const k of keys) {
67
- if (typeof args[k] === "string" && args[k].trim()) {
68
- return args[k].trim();
69
- }
70
- }
71
- return null;
72
- }
73
-
74
- /**
75
- * Checks whether an execution originates from a designated read-only reviewer agent.
76
- * Resolves: #65 (strict read-only reviewer subagent isolation).
77
- *
78
- * @param {any} execution
79
- * @param {any} [options]
80
- * @returns {boolean}
81
- */
82
- export function isReviewerAgent(execution, options = {}) {
83
- if (options && options.reviewerMode === true) return true;
84
- const agent = execution && execution.agent;
85
- if (!agent) return false;
86
-
87
- const role = typeof agent.role === "string" ? agent.role.toLowerCase() : "";
88
- const name = typeof agent.name === "string" ? agent.name.toLowerCase() : "";
89
- const type = typeof agent.type === "string" ? agent.type.toLowerCase() : "";
90
-
91
- return role.includes("review") || name.includes("review") || type.includes("review");
92
- }
93
-
94
- export class CommandDispatcher {
95
- /**
96
- * @param {{
97
- * toolName?: string,
98
- * fileWriteTools?: string[] | Set<string>,
99
- * readOnlyTools?: string[] | Set<string>,
100
- * networkTools?: string[] | Set<string>,
101
- * workspaceDir?: string
102
- * }} [options]
103
- */
104
- constructor(options = {}) {
105
- this.toolName = options.toolName || DEFAULT_BASH_TOOL;
106
- this.fileWriteTools = new Set(options.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
107
- this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
108
- this.networkTools = new Set(options.networkTools || DEFAULT_NETWORK_TOOLS);
109
- this.workspaceDir = options.workspaceDir || process.cwd();
110
- }
111
-
112
- /**
113
- * Categorizes the tool name into functional kind.
114
- *
115
- * @param {string} name
116
- * @returns {"shell" | "file_write" | "file_read" | "network" | "generic"}
117
- */
118
- classifyTool(name) {
119
- if (!name) return TOOL_KIND_GENERIC;
120
- if (name === this.toolName) return TOOL_KIND_SHELL;
121
- if (this.fileWriteTools.has(name)) return TOOL_KIND_FILE_WRITE;
122
- if (this.readOnlyTools.has(name)) return TOOL_KIND_FILE_READ;
123
- if (this.networkTools.has(name)) return TOOL_KIND_NETWORK;
124
- return TOOL_KIND_GENERIC;
125
- }
126
-
127
- /**
128
- * Dispatches and normalizes a tool execution request into a structured descriptor.
129
- *
130
- * @param {any} execution - The tool execution event carrying name, arguments, agent, session
131
- * @param {any} [options] - Dynamic context overrides
132
- * @returns {{
133
- * tool: string,
134
- * kind: "shell" | "file_write" | "file_read" | "network" | "generic",
135
- * isMutating: boolean,
136
- * reviewerViolation: boolean,
137
- * command?: string,
138
- * tokens?: any[],
139
- * subcommands?: string[],
140
- * git?: any,
141
- * silentError?: any,
142
- * loopback?: any,
143
- * filePath?: string,
144
- * pathSafety?: any
145
- * }}
146
- */
147
- dispatch(execution, options = {}) {
148
- const name = execution && execution.name ? String(execution.name).trim() : "";
149
- const args = execution ? execution.arguments : undefined;
150
- const kind = this.classifyTool(name);
151
- const isReviewer = isReviewerAgent(execution, options);
152
-
153
- const isMutating = kind === TOOL_KIND_SHELL || kind === TOOL_KIND_FILE_WRITE;
154
- const reviewerViolation = isReviewer && isMutating;
155
-
156
- const descriptor = {
157
- tool: name,
158
- kind,
159
- isMutating,
160
- reviewerViolation,
161
- };
162
-
163
- if (kind === TOOL_KIND_SHELL) {
164
- const rawCommand = args && typeof args === "object" ? args.command : (typeof args === "string" ? args : "");
165
- descriptor.command = typeof rawCommand === "string" ? rawCommand : "";
166
-
167
- if (descriptor.command) {
168
- const tokenized = tokenize(descriptor.command);
169
- descriptor.tokens = tokenized.tokens || [];
170
- descriptor.tokenizerOk = tokenized.ok;
171
- descriptor.tokenizerReason = tokenized.reason;
172
-
173
- // Subcommands breakdown
174
- descriptor.subcommands = splitSubcommands(descriptor.command);
175
-
176
- // Silent errors inspection (#90)
177
- descriptor.silentError = inspectSilentErrors(descriptor.command);
178
-
179
- // Git inspection (#125, #103)
180
- if (/\bgit\b/.test(descriptor.command)) {
181
- const firstWords = descriptor.command.trim().split(/\s+/);
182
- descriptor.git = parseGitArgv(firstWords);
183
- descriptor.gitClassification = classifyGitOperation(descriptor.git.verb, descriptor.git.args);
184
- }
185
-
186
- // Loopback detection (#40)
187
- descriptor.loopback = isLoopbackCommand(descriptor.command.trim().split(/\s+/));
188
- }
189
- } else if (kind === TOOL_KIND_FILE_WRITE) {
190
- const filePath = extractFilePath(args);
191
- descriptor.filePath = filePath;
192
- if (filePath) {
193
- const ws = options.workspaceDir || this.workspaceDir;
194
- descriptor.pathSafety = resolveSafePath(filePath, ws);
195
- descriptor.isProtected = isProtectedConfigPath(descriptor.pathSafety.canonical || filePath);
196
- descriptor.isTraversal = !descriptor.pathSafety.safe;
197
- }
198
- } else if (kind === TOOL_KIND_NETWORK) {
199
- const url = args && typeof args === "object" ? (args.url || args.Url || args.uri || args.Uri) : "";
200
- descriptor.url = typeof url === "string" ? url : "";
201
- if (descriptor.url) {
202
- descriptor.isLoopback = isLoopbackUrl(descriptor.url);
203
- }
204
- }
205
-
206
- return descriptor;
207
- }
1
+ // lib/dispatcher.js
2
+ // Command Dispatcher: architectural isolation of command parsing and syntactic dispatch
3
+ // from the safety evaluation engine.
4
+ // Resolves: #133, #65
5
+
6
+ import { tokenize, splitSubcommands } from "./tokenizer.js";
7
+ import { parseGitArgv, classifyGitOperation } from "./parsers/git.js";
8
+ import { extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
9
+ import { isLoopbackCommand, isLoopbackUrl } from "./loopback.js";
10
+ import { resolveSafePath, isPathTraversal, isProtectedConfigPath } from "./paths.js";
11
+
12
+ export const TOOL_KIND_SHELL = "shell";
13
+ export const TOOL_KIND_FILE_WRITE = "file_write";
14
+ export const TOOL_KIND_FILE_READ = "file_read";
15
+ export const TOOL_KIND_NETWORK = "network";
16
+ export const TOOL_KIND_GENERIC = "generic";
17
+
18
+ export const DEFAULT_BASH_TOOL = "bash";
19
+ export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
20
+ "write",
21
+ "edit",
22
+ "Write",
23
+ "Edit",
24
+ "write_to_file",
25
+ "replace_file_content",
26
+ "str_replace_editor",
27
+ "str_replace",
28
+ "apply_patch",
29
+ ]);
30
+
31
+ export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
32
+ "read_file",
33
+ "view_file",
34
+ "read_url_content",
35
+ "search_web",
36
+ "list_dir",
37
+ "glob",
38
+ "grep",
39
+ ]);
40
+
41
+ export const DEFAULT_NETWORK_TOOLS = Object.freeze([
42
+ "fetch",
43
+ "web_fetch",
44
+ "http_request",
45
+ "read_url_content",
46
+ ]);
47
+
48
+ /**
49
+ * Extracts target file path from various tool argument schemas.
50
+ *
51
+ * @param {any} args
52
+ * @returns {string | null}
53
+ */
54
+ export function extractFilePath(args) {
55
+ if (typeof args === "string") return args;
56
+ if (!args || typeof args !== "object") return null;
57
+ const keys = [
58
+ "path",
59
+ "file_path",
60
+ "filePath",
61
+ "file",
62
+ "filename",
63
+ "target",
64
+ "to",
65
+ "TargetFile",
66
+ "AbsolutePath",
67
+ ];
68
+ for (const k of keys) {
69
+ if (typeof args[k] === "string" && args[k].trim()) {
70
+ return args[k].trim();
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Checks whether an execution originates from a designated read-only reviewer agent.
78
+ * Resolves: #65 (strict read-only reviewer subagent isolation).
79
+ *
80
+ * @param {any} execution
81
+ * @param {any} [options]
82
+ * @returns {boolean}
83
+ */
84
+ export function isReviewerAgent(execution, options = {}) {
85
+ if (options && options.reviewerMode === true) return true;
86
+ const agent = execution && execution.agent;
87
+ if (!agent) return false;
88
+
89
+ const role = typeof agent.role === "string" ? agent.role.toLowerCase() : "";
90
+ const name = typeof agent.name === "string" ? agent.name.toLowerCase() : "";
91
+ const type = typeof agent.type === "string" ? agent.type.toLowerCase() : "";
92
+
93
+ return role.includes("review") || name.includes("review") || type.includes("review");
94
+ }
95
+
96
+ export class CommandDispatcher {
97
+ /**
98
+ * @param {{
99
+ * toolName?: string,
100
+ * fileWriteTools?: string[] | Set<string>,
101
+ * readOnlyTools?: string[] | Set<string>,
102
+ * networkTools?: string[] | Set<string>,
103
+ * workspaceDir?: string
104
+ * }} [options]
105
+ */
106
+ constructor(options = {}) {
107
+ this.toolName = options.toolName || DEFAULT_BASH_TOOL;
108
+ this.fileWriteTools = new Set(options.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
109
+ this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
110
+ this.networkTools = new Set(options.networkTools || DEFAULT_NETWORK_TOOLS);
111
+ this.workspaceDir = options.workspaceDir || process.cwd();
112
+ }
113
+
114
+ /**
115
+ * Categorizes the tool name into functional kind.
116
+ *
117
+ * @param {string} name
118
+ * @returns {"shell" | "file_write" | "file_read" | "network" | "generic"}
119
+ */
120
+ classifyTool(name) {
121
+ if (!name) return TOOL_KIND_GENERIC;
122
+ if (name === this.toolName) return TOOL_KIND_SHELL;
123
+ if (this.fileWriteTools.has(name)) return TOOL_KIND_FILE_WRITE;
124
+ if (this.readOnlyTools.has(name)) return TOOL_KIND_FILE_READ;
125
+ if (this.networkTools.has(name)) return TOOL_KIND_NETWORK;
126
+ return TOOL_KIND_GENERIC;
127
+ }
128
+
129
+ /**
130
+ * Dispatches and normalizes a tool execution request into a structured descriptor.
131
+ *
132
+ * @param {any} execution - The tool execution event carrying name, arguments, agent, session
133
+ * @param {any} [options] - Dynamic context overrides
134
+ * @returns {{
135
+ * tool: string,
136
+ * kind: "shell" | "file_write" | "file_read" | "network" | "generic",
137
+ * isMutating: boolean,
138
+ * reviewerViolation: boolean,
139
+ * command?: string,
140
+ * tokens?: any[],
141
+ * subcommands?: string[],
142
+ * git?: any,
143
+ * silentError?: any,
144
+ * loopback?: any,
145
+ * filePath?: string,
146
+ * pathSafety?: any
147
+ * }}
148
+ */
149
+ dispatch(execution, options = {}) {
150
+ const name = execution && execution.name ? String(execution.name).trim() : "";
151
+ const args = execution ? execution.arguments : undefined;
152
+ const kind = this.classifyTool(name);
153
+ const isReviewer = isReviewerAgent(execution, options);
154
+
155
+ const isMutating = kind === TOOL_KIND_SHELL || kind === TOOL_KIND_FILE_WRITE;
156
+ const reviewerViolation = isReviewer && isMutating;
157
+
158
+ const descriptor = {
159
+ tool: name,
160
+ kind,
161
+ isMutating,
162
+ reviewerViolation,
163
+ };
164
+
165
+ if (kind === TOOL_KIND_SHELL) {
166
+ const rawCommand = args && typeof args === "object" ? args.command : (typeof args === "string" ? args : "");
167
+ descriptor.command = typeof rawCommand === "string" ? rawCommand : "";
168
+
169
+ if (descriptor.command) {
170
+ const tokenized = tokenize(descriptor.command);
171
+ descriptor.tokens = tokenized.tokens || [];
172
+ descriptor.tokenizerOk = tokenized.ok;
173
+ descriptor.tokenizerReason = tokenized.reason;
174
+
175
+ // Subcommands breakdown
176
+ descriptor.subcommands = splitSubcommands(descriptor.command);
177
+
178
+ // Silent errors inspection (#90)
179
+ descriptor.silentError = inspectSilentErrors(descriptor.command);
180
+
181
+ // Git inspection (#125, #103)
182
+ if (/\bgit\b/.test(descriptor.command)) {
183
+ const firstWords = descriptor.command.trim().split(/\s+/);
184
+ descriptor.git = parseGitArgv(firstWords);
185
+ descriptor.gitClassification = classifyGitOperation(descriptor.git.verb, descriptor.git.args);
186
+ }
187
+
188
+ // Loopback detection (#40)
189
+ descriptor.loopback = isLoopbackCommand(descriptor.command.trim().split(/\s+/));
190
+ }
191
+ } else if (kind === TOOL_KIND_FILE_WRITE) {
192
+ const filePath = extractFilePath(args);
193
+ descriptor.filePath = filePath;
194
+ if (filePath) {
195
+ const ws = options.workspaceDir || this.workspaceDir;
196
+ descriptor.pathSafety = resolveSafePath(filePath, ws);
197
+ descriptor.isProtected = isProtectedConfigPath(descriptor.pathSafety.canonical || filePath);
198
+ descriptor.isTraversal = !descriptor.pathSafety.safe;
199
+ }
200
+ } else if (kind === TOOL_KIND_NETWORK) {
201
+ const url = args && typeof args === "object" ? (args.url || args.Url || args.uri || args.Uri) : "";
202
+ descriptor.url = typeof url === "string" ? url : "";
203
+ if (descriptor.url) {
204
+ descriptor.isLoopback = isLoopbackUrl(descriptor.url);
205
+ }
206
+ }
207
+
208
+ return descriptor;
209
+ }
208
210
  }
package/lib/engine.js CHANGED
@@ -17,6 +17,11 @@ import { QualityLedger } from "./ledger.js";
17
17
  import { AuditLogger } from "./audit-log.js";
18
18
  import { DeclarativeRulesEngine } from "./rules-loader.js";
19
19
  import { BlockCounters } from "./counters.js";
20
+ import { ContractGuard } from "./contract-guard.js";
21
+ import { OverengineeringGuard } from "./overengineering.js";
22
+ import { extractFilePath, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
23
+ import { categorizeRiskBucket, extractRiskTags } from "./risk.js";
24
+ import { ClassifierLatencyTracker } from "./latency.js";
20
25
 
21
26
  /**
22
27
  * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
@@ -112,6 +117,15 @@ export class SecurityEngine {
112
117
  this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
113
118
  workspaceDir: this.inspectConfig?.workspaceDir,
114
119
  });
120
+ this.contractGuard = options.contractGuard !== undefined
121
+ ? options.contractGuard
122
+ : (options.taskContract ? new ContractGuard(options.taskContract) : null);
123
+ this.overengineeringGuard = options.overengineeringGuard !== undefined
124
+ ? options.overengineeringGuard
125
+ : (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined
126
+ ? new OverengineeringGuard(options)
127
+ : null);
128
+ this.latencyTracker = options.latencyTracker || new ClassifierLatencyTracker(options);
115
129
  }
116
130
 
117
131
  /**
@@ -141,9 +155,9 @@ export class SecurityEngine {
141
155
  if (this.auditLogger) this.auditLogger.appendAuditLine({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
142
156
 
143
157
  if (this.dryRun) {
144
- 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);
145
159
  }
146
- return exhDecision;
160
+ return this._enrichDecision(exhDecision, execution, context);
147
161
  }
148
162
 
149
163
  // Check Verdict Cache (#63, #119)
@@ -152,7 +166,7 @@ export class SecurityEngine {
152
166
  const cached = this.cache.get(cacheKey);
153
167
  if (cached) {
154
168
  if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
155
- return {
169
+ return this._enrichDecision({
156
170
  verdict: "allow",
157
171
  stage: cached.stage,
158
172
  dryRun: true,
@@ -161,14 +175,15 @@ export class SecurityEngine {
161
175
  detail: cached.detail,
162
176
  hit: cached.hit,
163
177
  cached: true,
164
- };
178
+ }, execution, context);
165
179
  }
166
180
  if (this.counters) this.counters.record(cached, execution);
167
- return cached;
181
+ return this._enrichDecision(cached, execution, context);
168
182
  }
169
183
  }
170
184
 
171
185
  let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
186
+ this._enrichDecision(decision, execution, context);
172
187
 
173
188
  // Post-decision hooks: ledger, retryGuard, cache
174
189
  if (decision.verdict === "deny") {
@@ -201,6 +216,7 @@ export class SecurityEngine {
201
216
  hit: auditDenyHit,
202
217
  auditFailed: true,
203
218
  };
219
+ this._enrichDecision(decision, execution, context);
204
220
  }
205
221
  }
206
222
 
@@ -211,7 +227,7 @@ export class SecurityEngine {
211
227
 
212
228
  // Shadow Mode (#128)
213
229
  if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
214
- return {
230
+ return this._enrichDecision({
215
231
  verdict: "allow",
216
232
  stage: decision.stage,
217
233
  dryRun: true,
@@ -220,7 +236,7 @@ export class SecurityEngine {
220
236
  detail: decision.detail,
221
237
  hit: decision.hit,
222
238
  lockedAsk: decision.lockedAsk,
223
- };
239
+ }, execution, context);
224
240
  }
225
241
 
226
242
  return decision;
@@ -233,12 +249,12 @@ export class SecurityEngine {
233
249
  hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
234
250
  };
235
251
  if (this.counters) this.counters.record(errDecision, execution);
236
- return errDecision;
252
+ return this._enrichDecision(errDecision, execution, context);
237
253
  }
238
254
  }
239
255
 
240
256
  /**
241
- * Asynchronous evaluation supporting async classifiers (#129, #118).
257
+ * Asynchronous evaluation supporting async classifiers and latency SLA tracking (#129, #118, #57).
242
258
  *
243
259
  * @param {{ name: string, arguments?: any }} execution
244
260
  * @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context]
@@ -248,15 +264,31 @@ export class SecurityEngine {
248
264
  if (this.classifier) {
249
265
  let resolvedVerdict = null;
250
266
  let caughtError = null;
251
- try {
252
- if (typeof this.classifier.classify === "function") {
253
- resolvedVerdict = await this.classifier.classify(execution, context);
254
- } else if (typeof this.classifier === "function") {
255
- const res = this.classifier(execution, context);
256
- resolvedVerdict = res && typeof res.then === "function" ? await res : res;
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
+ }
257
291
  }
258
- } catch (err) {
259
- caughtError = err;
260
292
  }
261
293
 
262
294
  const originalClassifier = this.classifier;
@@ -316,6 +348,42 @@ export class SecurityEngine {
316
348
  return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet, hit };
317
349
  }
318
350
 
351
+ // Task Contract Guard (#87)
352
+ if (this.contractGuard) {
353
+ const cg = this.contractGuard.check(toolName, command, execution);
354
+ if (!cg.allowed) {
355
+ const cgHit = {
356
+ ask: true,
357
+ reason: cg.reason || "contract-violation",
358
+ detail: cg.detail,
359
+ snippet: cg.snippet || command || toolName,
360
+ };
361
+ const res = { verdict: "ask", stage: "P4", reason: cg.reason || "contract-violation", detail: cg.detail, hit: cgHit };
362
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
363
+ return res;
364
+ }
365
+ }
366
+
367
+ // Overengineering Guard (#89)
368
+ const fileTools = new Set(this.inspectConfig?.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
369
+ if (this.overengineeringGuard && typeof toolName === "string" && fileTools.has(toolName)) {
370
+ const targetPath = extractFilePath(execution ? execution.arguments : undefined);
371
+ if (targetPath) {
372
+ const og = this.overengineeringGuard.checkAndRecord(sessionId, targetPath);
373
+ if (!og.allowed) {
374
+ const ogHit = {
375
+ ask: true,
376
+ reason: og.reason || "overengineering-file-limit",
377
+ detail: og.detail,
378
+ snippet: targetPath,
379
+ };
380
+ const res = { verdict: "ask", stage: "P4", reason: og.reason || "overengineering-file-limit", detail: og.detail, hit: ogHit };
381
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
382
+ return res;
383
+ }
384
+ }
385
+ }
386
+
319
387
  // P1: Session Grants
320
388
  if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
321
389
  const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
@@ -458,7 +526,32 @@ export class SecurityEngine {
458
526
 
459
527
  resume(sessionId, reason) {
460
528
  if (this.retryGuard) this.retryGuard.reset(sessionId);
529
+ if (this.overengineeringGuard) this.overengineeringGuard.reset(sessionId);
461
530
  if (this.cache) this.cache.clearSession(sessionId);
462
531
  return this.breaker.resume(sessionId, reason);
463
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
+ }
464
556
  }
557
+