@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 +8 -0
- package/lib/contract-guard.js +147 -0
- package/lib/dispatcher.js +209 -207
- package/lib/engine.js +48 -0
- package/lib/index.js +21 -1
- package/lib/inspect.js +573 -520
- package/lib/messages.js +39 -0
- package/lib/overengineering.js +97 -0
- package/lib/paths.js +378 -320
- package/package.json +1 -1
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
|
-
"
|
|
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,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
|
}
|