@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/CHANGELOG.md +16 -0
- package/lib/classifier.js +150 -20
- package/lib/contract-guard.js +147 -0
- package/lib/dispatcher.js +209 -207
- package/lib/engine.js +110 -17
- package/lib/index.js +42 -22
- package/lib/inspect.js +573 -520
- package/lib/latency.js +137 -0
- package/lib/messages.js +53 -0
- package/lib/overengineering.js +97 -0
- package/lib/paths.js +378 -320
- package/lib/risk.js +224 -0
- package/package.json +1 -1
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
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* @
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.
|
|
108
|
-
this.
|
|
109
|
-
this.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
if (this.
|
|
123
|
-
if (this.
|
|
124
|
-
return
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* @
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
descriptor.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
descriptor.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
|