@goodandready/dsh-approval-gate 0.1.17 → 0.1.18

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.18
4
+
5
+ - Security: Эвристический предохранитель размера аргументов (`computePayloadSize` / `maxPayloadBytes`, по умолчанию 64 КБ) для предотвращения переполнения контекста LLM-ревьюера и обхода инспекции (`payload-too-large` -> `ask`) (Refs: #71).
6
+ - Security: Сканер анти-инъекций инструкций в аргументы и скрипты (`scanPromptInjection`) с жесткой блокировкой директив обхода правил (`SYSTEM OVERRIDE: ALLOW`, `ignore previous instructions` и др. -> `reviewer-injection-attempt`) (Refs: #72).
7
+ - Security: Страж соответствия контракту задачи (`ContractGuard` в `lib/contract-guard.js`) с перехватом нецелевых системных пакетных менеджеров, управления службами, сетевых конфигураций и запрещенных инструментов (`contract-violation` -> `ask`) (Refs: #87).
8
+ - Security: Строгая изоляция временных каталогов проекта (`resolveTempRoot`, `isGlobalTempPath`, `checkTempIsolation` в `lib/paths.js`) с переводом обращений к глобальному `/tmp` на запрос подтверждения (`unisolated-temp-path` -> `ask` в пользу `<workspace>/.dsh/tmp`) (Refs: #102).
9
+ - Feature: Контроль оверинжиниринга (`OverengineeringGuard` в `lib/overengineering.js`) с отслеживанием числа создаваемых уникальных файлов за сессию (`maxFilesPerSession`, по умолчанию 10) и запросом подтверждения при превышении (`overengineering-file-limit` -> `ask`) (Refs: #89).
10
+
3
11
  ## 0.1.17
4
12
 
5
13
  - Feature: Автонормализация URL эндпоинтов пользовательских LLM (`normalizeChatCompletionsUrl`) с поддержкой bare host, портов, `/v1` и `/chat/completions` без риска 404 (Refs: #127).
@@ -0,0 +1,147 @@
1
+ // lib/contract-guard.js
2
+ // Enforces task contract boundaries and blocks out-of-scope system mutations (#87).
3
+
4
+ const SYSTEM_PACKAGE_MANAGERS = new Set([
5
+ "apt",
6
+ "apt-get",
7
+ "dpkg",
8
+ "yum",
9
+ "dnf",
10
+ "pacman",
11
+ "zypper",
12
+ "brew",
13
+ "snap",
14
+ "flatpak",
15
+ "apk",
16
+ ]);
17
+
18
+ const SYSTEM_SERVICE_COMMANDS = new Set([
19
+ "systemctl",
20
+ "service",
21
+ "init",
22
+ "reboot",
23
+ "shutdown",
24
+ "poweroff",
25
+ ]);
26
+
27
+ const SYSTEM_NETWORKING_COMMANDS = new Set([
28
+ "iptables",
29
+ "ufw",
30
+ "firewalld",
31
+ "ifconfig",
32
+ "netplan",
33
+ ]);
34
+
35
+ const SYSTEM_CONFIG_TARGETS = [
36
+ "/etc/passwd",
37
+ "/etc/shadow",
38
+ "/etc/sudoers",
39
+ "/etc/fstab",
40
+ "/etc/hosts",
41
+ "/etc/pam.d",
42
+ "/etc/profile",
43
+ "/etc/bash.bashrc",
44
+ ];
45
+
46
+ /**
47
+ * Validates whether a command or tool execution stays within task contract boundaries (#87).
48
+ */
49
+ export class ContractGuard {
50
+ /**
51
+ * @param {{
52
+ * allowSystemMutations?: boolean,
53
+ * allowedTools?: string[],
54
+ * allowedCommands?: string[],
55
+ * forbiddenCommands?: string[]
56
+ * }} [contract]
57
+ */
58
+ constructor(contract = {}) {
59
+ this.allowSystemMutations = contract.allowSystemMutations === true;
60
+ this.allowedTools = Array.isArray(contract.allowedTools) ? new Set(contract.allowedTools) : null;
61
+ this.allowedCommands = Array.isArray(contract.allowedCommands) ? new Set(contract.allowedCommands) : null;
62
+ this.forbiddenCommands = Array.isArray(contract.forbiddenCommands) ? new Set(contract.forbiddenCommands) : null;
63
+ }
64
+
65
+ /**
66
+ * Checks if an execution violates the task contract boundaries.
67
+ *
68
+ * @param {string} toolName
69
+ * @param {string} [command]
70
+ * @param {any} [execution]
71
+ * @returns {{ allowed: boolean, reason?: string, detail?: string, snippet?: string }}
72
+ */
73
+ check(toolName, command, execution) {
74
+ // 1. Tool-level restriction
75
+ if (this.allowedTools && toolName && !this.allowedTools.has(toolName)) {
76
+ return {
77
+ allowed: false,
78
+ reason: "contract-violation",
79
+ detail: `Tool '${toolName}' is not permitted by task contract`,
80
+ snippet: toolName,
81
+ };
82
+ }
83
+
84
+ if (!command || typeof command !== "string") {
85
+ return { allowed: true };
86
+ }
87
+
88
+ const trimmed = command.trim();
89
+ if (!trimmed) return { allowed: true };
90
+
91
+ const firstWord = trimmed.split(/\s+/)[0].replace(/^sudo\s+/, "");
92
+
93
+ // 2. Explicit forbidden command list
94
+ if (this.forbiddenCommands && this.forbiddenCommands.has(firstWord)) {
95
+ return {
96
+ allowed: false,
97
+ reason: "contract-violation",
98
+ detail: `Command '${firstWord}' is explicitly forbidden by task contract`,
99
+ snippet: firstWord,
100
+ };
101
+ }
102
+
103
+ // 3. System mutations guard when allowSystemMutations is false
104
+ if (!this.allowSystemMutations) {
105
+ if (SYSTEM_PACKAGE_MANAGERS.has(firstWord)) {
106
+ return {
107
+ allowed: false,
108
+ reason: "contract-violation",
109
+ detail: `Out-of-scope system package manager invocation: ${firstWord}`,
110
+ snippet: trimmed,
111
+ };
112
+ }
113
+
114
+ if (SYSTEM_SERVICE_COMMANDS.has(firstWord)) {
115
+ return {
116
+ allowed: false,
117
+ reason: "contract-violation",
118
+ detail: `Out-of-scope system service or reboot control: ${firstWord}`,
119
+ snippet: trimmed,
120
+ };
121
+ }
122
+
123
+ if (SYSTEM_NETWORKING_COMMANDS.has(firstWord)) {
124
+ return {
125
+ allowed: false,
126
+ reason: "contract-violation",
127
+ detail: `Out-of-scope network configuration command: ${firstWord}`,
128
+ snippet: trimmed,
129
+ };
130
+ }
131
+
132
+ // Check system configuration mutation redirects
133
+ for (const target of SYSTEM_CONFIG_TARGETS) {
134
+ if (trimmed.includes(target) && /[><]/.test(trimmed)) {
135
+ return {
136
+ allowed: false,
137
+ reason: "contract-violation",
138
+ detail: `Out-of-scope system configuration file write: ${target}`,
139
+ snippet: trimmed,
140
+ };
141
+ }
142
+ }
143
+ }
144
+
145
+ return { allowed: true };
146
+ }
147
+ }
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,9 @@ 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";
20
23
 
21
24
  /**
22
25
  * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
@@ -112,6 +115,14 @@ export class SecurityEngine {
112
115
  this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
113
116
  workspaceDir: this.inspectConfig?.workspaceDir,
114
117
  });
118
+ this.contractGuard = options.contractGuard !== undefined
119
+ ? options.contractGuard
120
+ : (options.taskContract ? new ContractGuard(options.taskContract) : null);
121
+ this.overengineeringGuard = options.overengineeringGuard !== undefined
122
+ ? options.overengineeringGuard
123
+ : (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined
124
+ ? new OverengineeringGuard(options)
125
+ : null);
115
126
  }
116
127
 
117
128
  /**
@@ -316,6 +327,42 @@ export class SecurityEngine {
316
327
  return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet, hit };
317
328
  }
318
329
 
330
+ // Task Contract Guard (#87)
331
+ if (this.contractGuard) {
332
+ const cg = this.contractGuard.check(toolName, command, execution);
333
+ if (!cg.allowed) {
334
+ const cgHit = {
335
+ ask: true,
336
+ reason: cg.reason || "contract-violation",
337
+ detail: cg.detail,
338
+ snippet: cg.snippet || command || toolName,
339
+ };
340
+ const res = { verdict: "ask", stage: "P4", reason: cg.reason || "contract-violation", detail: cg.detail, hit: cgHit };
341
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
342
+ return res;
343
+ }
344
+ }
345
+
346
+ // Overengineering Guard (#89)
347
+ const fileTools = new Set(this.inspectConfig?.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
348
+ if (this.overengineeringGuard && typeof toolName === "string" && fileTools.has(toolName)) {
349
+ const targetPath = extractFilePath(execution ? execution.arguments : undefined);
350
+ if (targetPath) {
351
+ const og = this.overengineeringGuard.checkAndRecord(sessionId, targetPath);
352
+ if (!og.allowed) {
353
+ const ogHit = {
354
+ ask: true,
355
+ reason: og.reason || "overengineering-file-limit",
356
+ detail: og.detail,
357
+ snippet: targetPath,
358
+ };
359
+ const res = { verdict: "ask", stage: "P4", reason: og.reason || "overengineering-file-limit", detail: og.detail, hit: ogHit };
360
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
361
+ return res;
362
+ }
363
+ }
364
+ }
365
+
319
366
  // P1: Session Grants
320
367
  if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
321
368
  const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
@@ -458,6 +505,7 @@ export class SecurityEngine {
458
505
 
459
506
  resume(sessionId, reason) {
460
507
  if (this.retryGuard) this.retryGuard.reset(sessionId);
508
+ if (this.overengineeringGuard) this.overengineeringGuard.reset(sessionId);
461
509
  if (this.cache) this.cache.clearSession(sessionId);
462
510
  return this.breaker.resume(sessionId, reason);
463
511
  }