codemini-cli 0.5.10 → 0.5.11
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/OPERATIONS.md +242 -242
- package/README.md +588 -588
- package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-7HL7yft8.js → highlighted-body-OFNGDK62-CANOG7Xg.js} +1 -1
- package/codemini-web/dist/assets/{index-BK75hMb2.js → index-B71xykPM.js} +108 -108
- package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Z_w7M93P.js +1 -0
- package/codemini-web/dist/index.html +23 -23
- package/codemini-web/lib/approval-manager.js +32 -32
- package/codemini-web/lib/runtime-bridge.js +17 -11
- package/codemini-web/server.js +534 -205
- package/deployment.md +212 -212
- package/package.json +1 -1
- package/skills/brainstorm/SKILL.md +77 -77
- package/skills/codemini.skills.json +40 -40
- package/skills/grill-me/SKILL.md +30 -30
- package/skills/superpowers-lite/SKILL.md +82 -82
- package/src/cli.js +74 -74
- package/src/commands/chat.js +210 -210
- package/src/commands/run.js +313 -313
- package/src/commands/skill.js +438 -304
- package/src/commands/web.js +57 -57
- package/src/core/agent-loop.js +980 -980
- package/src/core/ast.js +309 -307
- package/src/core/chat-runtime.js +6261 -6253
- package/src/core/command-evaluator.js +72 -72
- package/src/core/command-loader.js +311 -311
- package/src/core/command-policy.js +301 -301
- package/src/core/command-risk.js +156 -156
- package/src/core/config-store.js +289 -289
- package/src/core/constants.js +18 -1
- package/src/core/context-compact.js +365 -365
- package/src/core/default-system-prompt.js +114 -107
- package/src/core/dream-audit.js +105 -105
- package/src/core/dream-consolidate.js +229 -229
- package/src/core/dream-evaluator.js +185 -185
- package/src/core/fff-adapter.js +383 -383
- package/src/core/memory-store.js +543 -543
- package/src/core/project-index.js +737 -548
- package/src/core/project-instructions.js +98 -98
- package/src/core/provider/anthropic.js +514 -514
- package/src/core/provider/openai-compatible.js +501 -501
- package/src/core/reflect-skill.js +178 -178
- package/src/core/reply-language.js +40 -40
- package/src/core/session-store.js +474 -474
- package/src/core/shell-profile.js +237 -237
- package/src/core/shell.js +323 -323
- package/src/core/soul.js +69 -69
- package/src/core/system-prompt-composer.js +52 -52
- package/src/core/tool-args.js +199 -154
- package/src/core/tool-output.js +184 -184
- package/src/core/tool-result-store.js +206 -206
- package/src/core/tools.js +3024 -2893
- package/src/core/version.js +11 -11
- package/src/tui/chat-app.js +5171 -5171
- package/src/tui/tool-activity/presenters/misc.js +30 -30
- package/src/tui/tool-activity/presenters/system.js +20 -20
- package/templates/project-requirements/report-shell.html +582 -582
- package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Dg9qh8mg.js +0 -1
|
@@ -1,301 +1,301 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { getEffectivePolicy } from './shell-profile.js';
|
|
3
|
-
|
|
4
|
-
const SHELL_KEYWORDS = new Set([
|
|
5
|
-
'if',
|
|
6
|
-
'then',
|
|
7
|
-
'elif',
|
|
8
|
-
'else',
|
|
9
|
-
'fi',
|
|
10
|
-
'for',
|
|
11
|
-
'while',
|
|
12
|
-
'until',
|
|
13
|
-
'do',
|
|
14
|
-
'done',
|
|
15
|
-
'case',
|
|
16
|
-
'esac',
|
|
17
|
-
'in',
|
|
18
|
-
'function',
|
|
19
|
-
'time',
|
|
20
|
-
'{',
|
|
21
|
-
'}'
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
function firstToken(command) {
|
|
25
|
-
const m = String(command || '').trim().match(/^"([^"]+)"|^'([^']+)'|^(\S+)/);
|
|
26
|
-
const raw = (m && (m[1] || m[2] || m[3])) || '';
|
|
27
|
-
const base = path.basename(raw).toLowerCase();
|
|
28
|
-
return base.replace(/\.exe$/i, '');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function splitCommandSegments(command) {
|
|
32
|
-
const text = String(command || '').trim();
|
|
33
|
-
if (!text) return [];
|
|
34
|
-
const segments = [];
|
|
35
|
-
let current = '';
|
|
36
|
-
let quote = '';
|
|
37
|
-
let escapeNext = false;
|
|
38
|
-
|
|
39
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
40
|
-
const ch = text[i];
|
|
41
|
-
const next = text[i + 1];
|
|
42
|
-
|
|
43
|
-
if (escapeNext) {
|
|
44
|
-
current += ch;
|
|
45
|
-
escapeNext = false;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (ch === '\\' && quote !== '\'') {
|
|
50
|
-
current += ch;
|
|
51
|
-
escapeNext = true;
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (quote) {
|
|
56
|
-
current += ch;
|
|
57
|
-
if (ch === quote) quote = '';
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (ch === '"' || ch === '\'') {
|
|
62
|
-
quote = ch;
|
|
63
|
-
current += ch;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if ((ch === '&' && next === '&') || (ch === '|' && next === '|')) {
|
|
68
|
-
if (current.trim()) segments.push(current.trim());
|
|
69
|
-
current = '';
|
|
70
|
-
i += 1;
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (ch === '&' && text[i - 1] === '>') {
|
|
75
|
-
current += ch;
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (ch === '|' || ch === ';' || ch === '&') {
|
|
80
|
-
if (current.trim()) segments.push(current.trim());
|
|
81
|
-
current = '';
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
current += ch;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (current.trim()) segments.push(current.trim());
|
|
89
|
-
return segments;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function tokenizeTopLevel(command) {
|
|
93
|
-
const text = String(command || '').trim();
|
|
94
|
-
if (!text) return [];
|
|
95
|
-
const tokens = [];
|
|
96
|
-
let current = '';
|
|
97
|
-
let quote = '';
|
|
98
|
-
let escapeNext = false;
|
|
99
|
-
|
|
100
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
101
|
-
const ch = text[i];
|
|
102
|
-
if (escapeNext) {
|
|
103
|
-
current += ch;
|
|
104
|
-
escapeNext = false;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
if (ch === '\\' && quote !== '\'') {
|
|
108
|
-
escapeNext = true;
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
if (quote) {
|
|
112
|
-
if (ch === quote) {
|
|
113
|
-
quote = '';
|
|
114
|
-
} else {
|
|
115
|
-
current += ch;
|
|
116
|
-
}
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
if (ch === '"' || ch === '\'') {
|
|
120
|
-
quote = ch;
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if (/\s/.test(ch)) {
|
|
124
|
-
if (current) {
|
|
125
|
-
tokens.push(current);
|
|
126
|
-
current = '';
|
|
127
|
-
}
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
current += ch;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (current) tokens.push(current);
|
|
134
|
-
return tokens;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function unwrapShellPayload(command) {
|
|
138
|
-
const tokens = tokenizeTopLevel(command);
|
|
139
|
-
const token = firstToken(command);
|
|
140
|
-
if (!['bash', 'sh', 'zsh', 'powershell', 'pwsh', 'cmd'].includes(token)) return '';
|
|
141
|
-
|
|
142
|
-
const index = tokens.findIndex((item, itemIndex) => {
|
|
143
|
-
if (token === 'cmd') return itemIndex > 0 && /^\/c$/i.test(item);
|
|
144
|
-
return /^-(?:c|lc|command)$/i.test(item);
|
|
145
|
-
});
|
|
146
|
-
if (index < 0 || index + 1 >= tokens.length) return '';
|
|
147
|
-
return tokens.slice(index + 1).join(' ').trim();
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function collectCommandTokens(command) {
|
|
151
|
-
const cmd = String(command || '').trim();
|
|
152
|
-
if (!cmd) return [];
|
|
153
|
-
|
|
154
|
-
const chained = splitCommandSegments(cmd);
|
|
155
|
-
if (chained.length > 1) {
|
|
156
|
-
return chained.flatMap((segment) => collectCommandTokens(segment));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const token = firstToken(cmd);
|
|
160
|
-
const out = token ? [{ token, raw: cmd }] : [];
|
|
161
|
-
const wrapped = unwrapShellPayload(cmd);
|
|
162
|
-
if (wrapped && wrapped !== cmd) {
|
|
163
|
-
out.push(...collectCommandTokens(wrapped));
|
|
164
|
-
}
|
|
165
|
-
return out;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function includesAny(haystackLower, patterns = []) {
|
|
169
|
-
return patterns.some((p) => haystackLower.includes(String(p).toLowerCase()));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** bash 下会被阻止的删除类命令 token */
|
|
173
|
-
const BASH_DELETE_TOKENS = new Set(['rm', 'rmdir']);
|
|
174
|
-
/** PowerShell 下会被阻止的删除类命令 token */
|
|
175
|
-
const POWERSHELL_DELETE_TOKENS = new Set(['del', 'erase', 'rmdir', 'rd', 'remove-item', 'ri']);
|
|
176
|
-
|
|
177
|
-
function suggestionForToken(token, config) {
|
|
178
|
-
const shell = String(config?.shell?.default || '').toLowerCase();
|
|
179
|
-
|
|
180
|
-
/* 删除类命令:优先引导 LLM 使用 delete 工具 */
|
|
181
|
-
if (
|
|
182
|
-
(shell !== 'powershell' && BASH_DELETE_TOKENS.has(token)) ||
|
|
183
|
-
(shell === 'powershell' && POWERSHELL_DELETE_TOKENS.has(token))
|
|
184
|
-
) {
|
|
185
|
-
return 'Use the delete tool to remove files or directories inside the workspace. Do not use shell commands for deletion.';
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (token === 'find' || token === 'grep') {
|
|
189
|
-
return shell === 'powershell'
|
|
190
|
-
? 'Prefer structured tools like grep, list, read, and edit first. If you need shell fallback, use allowed search and context commands such as Get-ChildItem, Select-String, Get-Content, or rg when available.'
|
|
191
|
-
: 'Prefer structured tools like grep, glob, list, read, and edit first. If you need shell fallback, use allowed search and context commands such as rg, find, grep, sed, cat, or ls.';
|
|
192
|
-
}
|
|
193
|
-
if (shell === 'powershell') {
|
|
194
|
-
return 'Prefer structured tools like read, edit, write, grep, and list first. If you need shell fallback, use allowed shell commands for search and local context such as Get-ChildItem, Get-Content, Select-String, or rg when available.';
|
|
195
|
-
}
|
|
196
|
-
return 'Prefer structured tools like read, edit, write, grep, glob, and list first. If you need shell fallback, use allowed shell commands for search and local context such as rg, find, grep, sed, cat, or ls.';
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function allowedPathRoots(workspaceRoot, config = {}) {
|
|
200
|
-
return [
|
|
201
|
-
workspaceRoot,
|
|
202
|
-
...(Array.isArray(config?.policy?.allowed_paths) ? config.policy.allowed_paths : [])
|
|
203
|
-
]
|
|
204
|
-
.map((item) => String(item || '').trim())
|
|
205
|
-
.filter(Boolean)
|
|
206
|
-
.map((item) => path.resolve(item));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function isWithinAnyRoot(candidatePath, roots = []) {
|
|
210
|
-
const resolvedCandidate = path.resolve(candidatePath);
|
|
211
|
-
return roots.some((root) => {
|
|
212
|
-
const relative = path.relative(root, resolvedCandidate);
|
|
213
|
-
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function validateCdSegment(command, workspaceRoot, config = {}) {
|
|
218
|
-
const tokens = tokenizeTopLevel(command);
|
|
219
|
-
if (tokens.length === 1) {
|
|
220
|
-
return { allowed: false, reason: 'cd requires a target path in safe mode' };
|
|
221
|
-
}
|
|
222
|
-
if (tokens.length !== 2) {
|
|
223
|
-
return { allowed: false, reason: 'cd only supports a single target path in safe mode' };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const rawTarget = String(tokens[1] || '').trim();
|
|
227
|
-
if (!rawTarget || rawTarget.startsWith('-')) {
|
|
228
|
-
return { allowed: false, reason: 'cd target is not allowed in safe mode' };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const resolvedTarget = path.resolve(path.resolve(workspaceRoot), rawTarget);
|
|
232
|
-
if (!isWithinAnyRoot(resolvedTarget, allowedPathRoots(workspaceRoot, config))) {
|
|
233
|
-
return { allowed: false, reason: `cd escapes workspace or allowed paths: ${rawTarget}` };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return { allowed: true };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export function evaluateCommandPolicy(command, config, workspaceRoot = process.cwd()) {
|
|
240
|
-
const policy = getEffectivePolicy(config);
|
|
241
|
-
const cmd = String(command || '').trim();
|
|
242
|
-
const lower = cmd.toLowerCase();
|
|
243
|
-
if (!cmd) {
|
|
244
|
-
return { allowed: false, reason: 'empty command' };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (!policy.allow_dangerous_commands && includesAny(lower, policy.blocked_command_patterns)) {
|
|
248
|
-
return { allowed: false, reason: 'blocked by dangerous command pattern' };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (!policy.safe_mode) {
|
|
252
|
-
return { allowed: true };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (includesAny(lower, policy.blocked_path_patterns)) {
|
|
256
|
-
return { allowed: false, reason: 'blocked protected system path' };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const token = firstToken(cmd);
|
|
260
|
-
const inspectedTokens = collectCommandTokens(cmd);
|
|
261
|
-
const allowlist = Array.isArray(policy.command_allowlist) ? policy.command_allowlist : [];
|
|
262
|
-
for (const item of inspectedTokens) {
|
|
263
|
-
if (SHELL_KEYWORDS.has(item.token)) continue;
|
|
264
|
-
if (item.token === 'cd') {
|
|
265
|
-
const cdCheck = validateCdSegment(item.raw, workspaceRoot, config);
|
|
266
|
-
if (!cdCheck.allowed) {
|
|
267
|
-
return { allowed: false, reason: cdCheck.reason, suggestion: suggestionForToken(item.token, config) };
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
if (includesAny(item.token, policy.blocked_commands)) {
|
|
271
|
-
return { allowed: false, reason: `blocked command: ${item.token}`, suggestion: suggestionForToken(item.token, config) };
|
|
272
|
-
}
|
|
273
|
-
if (allowlist.length > 0 && !allowlist.includes(item.token)) {
|
|
274
|
-
return {
|
|
275
|
-
allowed: false,
|
|
276
|
-
reason: `command not in allowlist: ${item.token}`,
|
|
277
|
-
suggestion: suggestionForToken(item.token, config)
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const allowedLower = allowedPathRoots(workspaceRoot, config).map((item) => item.toLowerCase().replace(/\//g, '\\'));
|
|
283
|
-
const windowsAbsPath = lower.match(/[a-z]:\\[^\s'"]+/g) || [];
|
|
284
|
-
for (const p of windowsAbsPath) {
|
|
285
|
-
if (!allowedLower.some((root) => p === root || p.startsWith(`${root}\\`))) {
|
|
286
|
-
return { allowed: false, reason: `absolute path outside workspace or allowed paths: ${p}`, suggestion: suggestionForToken(token, config) };
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const posixAbsPath = cmd.match(/(?<![:/\w])\/(?!\/)[^\s'"]+/g) || [];
|
|
291
|
-
const allowedResolved = allowedPathRoots(workspaceRoot, config);
|
|
292
|
-
for (const p of posixAbsPath) {
|
|
293
|
-
if (!isWithinAnyRoot(p, allowedResolved)) {
|
|
294
|
-
return { allowed: false, reason: `absolute path outside workspace or allowed paths: ${p}`, suggestion: suggestionForToken(token, config) };
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return { allowed: true };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export { collectCommandTokens, firstToken };
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getEffectivePolicy } from './shell-profile.js';
|
|
3
|
+
|
|
4
|
+
const SHELL_KEYWORDS = new Set([
|
|
5
|
+
'if',
|
|
6
|
+
'then',
|
|
7
|
+
'elif',
|
|
8
|
+
'else',
|
|
9
|
+
'fi',
|
|
10
|
+
'for',
|
|
11
|
+
'while',
|
|
12
|
+
'until',
|
|
13
|
+
'do',
|
|
14
|
+
'done',
|
|
15
|
+
'case',
|
|
16
|
+
'esac',
|
|
17
|
+
'in',
|
|
18
|
+
'function',
|
|
19
|
+
'time',
|
|
20
|
+
'{',
|
|
21
|
+
'}'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function firstToken(command) {
|
|
25
|
+
const m = String(command || '').trim().match(/^"([^"]+)"|^'([^']+)'|^(\S+)/);
|
|
26
|
+
const raw = (m && (m[1] || m[2] || m[3])) || '';
|
|
27
|
+
const base = path.basename(raw).toLowerCase();
|
|
28
|
+
return base.replace(/\.exe$/i, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function splitCommandSegments(command) {
|
|
32
|
+
const text = String(command || '').trim();
|
|
33
|
+
if (!text) return [];
|
|
34
|
+
const segments = [];
|
|
35
|
+
let current = '';
|
|
36
|
+
let quote = '';
|
|
37
|
+
let escapeNext = false;
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
40
|
+
const ch = text[i];
|
|
41
|
+
const next = text[i + 1];
|
|
42
|
+
|
|
43
|
+
if (escapeNext) {
|
|
44
|
+
current += ch;
|
|
45
|
+
escapeNext = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (ch === '\\' && quote !== '\'') {
|
|
50
|
+
current += ch;
|
|
51
|
+
escapeNext = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (quote) {
|
|
56
|
+
current += ch;
|
|
57
|
+
if (ch === quote) quote = '';
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (ch === '"' || ch === '\'') {
|
|
62
|
+
quote = ch;
|
|
63
|
+
current += ch;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if ((ch === '&' && next === '&') || (ch === '|' && next === '|')) {
|
|
68
|
+
if (current.trim()) segments.push(current.trim());
|
|
69
|
+
current = '';
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (ch === '&' && text[i - 1] === '>') {
|
|
75
|
+
current += ch;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (ch === '|' || ch === ';' || ch === '&') {
|
|
80
|
+
if (current.trim()) segments.push(current.trim());
|
|
81
|
+
current = '';
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
current += ch;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (current.trim()) segments.push(current.trim());
|
|
89
|
+
return segments;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function tokenizeTopLevel(command) {
|
|
93
|
+
const text = String(command || '').trim();
|
|
94
|
+
if (!text) return [];
|
|
95
|
+
const tokens = [];
|
|
96
|
+
let current = '';
|
|
97
|
+
let quote = '';
|
|
98
|
+
let escapeNext = false;
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
101
|
+
const ch = text[i];
|
|
102
|
+
if (escapeNext) {
|
|
103
|
+
current += ch;
|
|
104
|
+
escapeNext = false;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (ch === '\\' && quote !== '\'') {
|
|
108
|
+
escapeNext = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (quote) {
|
|
112
|
+
if (ch === quote) {
|
|
113
|
+
quote = '';
|
|
114
|
+
} else {
|
|
115
|
+
current += ch;
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (ch === '"' || ch === '\'') {
|
|
120
|
+
quote = ch;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (/\s/.test(ch)) {
|
|
124
|
+
if (current) {
|
|
125
|
+
tokens.push(current);
|
|
126
|
+
current = '';
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
current += ch;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (current) tokens.push(current);
|
|
134
|
+
return tokens;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function unwrapShellPayload(command) {
|
|
138
|
+
const tokens = tokenizeTopLevel(command);
|
|
139
|
+
const token = firstToken(command);
|
|
140
|
+
if (!['bash', 'sh', 'zsh', 'powershell', 'pwsh', 'cmd'].includes(token)) return '';
|
|
141
|
+
|
|
142
|
+
const index = tokens.findIndex((item, itemIndex) => {
|
|
143
|
+
if (token === 'cmd') return itemIndex > 0 && /^\/c$/i.test(item);
|
|
144
|
+
return /^-(?:c|lc|command)$/i.test(item);
|
|
145
|
+
});
|
|
146
|
+
if (index < 0 || index + 1 >= tokens.length) return '';
|
|
147
|
+
return tokens.slice(index + 1).join(' ').trim();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function collectCommandTokens(command) {
|
|
151
|
+
const cmd = String(command || '').trim();
|
|
152
|
+
if (!cmd) return [];
|
|
153
|
+
|
|
154
|
+
const chained = splitCommandSegments(cmd);
|
|
155
|
+
if (chained.length > 1) {
|
|
156
|
+
return chained.flatMap((segment) => collectCommandTokens(segment));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const token = firstToken(cmd);
|
|
160
|
+
const out = token ? [{ token, raw: cmd }] : [];
|
|
161
|
+
const wrapped = unwrapShellPayload(cmd);
|
|
162
|
+
if (wrapped && wrapped !== cmd) {
|
|
163
|
+
out.push(...collectCommandTokens(wrapped));
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function includesAny(haystackLower, patterns = []) {
|
|
169
|
+
return patterns.some((p) => haystackLower.includes(String(p).toLowerCase()));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** bash 下会被阻止的删除类命令 token */
|
|
173
|
+
const BASH_DELETE_TOKENS = new Set(['rm', 'rmdir']);
|
|
174
|
+
/** PowerShell 下会被阻止的删除类命令 token */
|
|
175
|
+
const POWERSHELL_DELETE_TOKENS = new Set(['del', 'erase', 'rmdir', 'rd', 'remove-item', 'ri']);
|
|
176
|
+
|
|
177
|
+
function suggestionForToken(token, config) {
|
|
178
|
+
const shell = String(config?.shell?.default || '').toLowerCase();
|
|
179
|
+
|
|
180
|
+
/* 删除类命令:优先引导 LLM 使用 delete 工具 */
|
|
181
|
+
if (
|
|
182
|
+
(shell !== 'powershell' && BASH_DELETE_TOKENS.has(token)) ||
|
|
183
|
+
(shell === 'powershell' && POWERSHELL_DELETE_TOKENS.has(token))
|
|
184
|
+
) {
|
|
185
|
+
return 'Use the delete tool to remove files or directories inside the workspace. Do not use shell commands for deletion.';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (token === 'find' || token === 'grep') {
|
|
189
|
+
return shell === 'powershell'
|
|
190
|
+
? 'Prefer structured tools like grep, list, read, and edit first. If you need shell fallback, use allowed search and context commands such as Get-ChildItem, Select-String, Get-Content, or rg when available.'
|
|
191
|
+
: 'Prefer structured tools like grep, glob, list, read, and edit first. If you need shell fallback, use allowed search and context commands such as rg, find, grep, sed, cat, or ls.';
|
|
192
|
+
}
|
|
193
|
+
if (shell === 'powershell') {
|
|
194
|
+
return 'Prefer structured tools like read, edit, write, grep, and list first. If you need shell fallback, use allowed shell commands for search and local context such as Get-ChildItem, Get-Content, Select-String, or rg when available.';
|
|
195
|
+
}
|
|
196
|
+
return 'Prefer structured tools like read, edit, write, grep, glob, and list first. If you need shell fallback, use allowed shell commands for search and local context such as rg, find, grep, sed, cat, or ls.';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function allowedPathRoots(workspaceRoot, config = {}) {
|
|
200
|
+
return [
|
|
201
|
+
workspaceRoot,
|
|
202
|
+
...(Array.isArray(config?.policy?.allowed_paths) ? config.policy.allowed_paths : [])
|
|
203
|
+
]
|
|
204
|
+
.map((item) => String(item || '').trim())
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.map((item) => path.resolve(item));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isWithinAnyRoot(candidatePath, roots = []) {
|
|
210
|
+
const resolvedCandidate = path.resolve(candidatePath);
|
|
211
|
+
return roots.some((root) => {
|
|
212
|
+
const relative = path.relative(root, resolvedCandidate);
|
|
213
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function validateCdSegment(command, workspaceRoot, config = {}) {
|
|
218
|
+
const tokens = tokenizeTopLevel(command);
|
|
219
|
+
if (tokens.length === 1) {
|
|
220
|
+
return { allowed: false, reason: 'cd requires a target path in safe mode' };
|
|
221
|
+
}
|
|
222
|
+
if (tokens.length !== 2) {
|
|
223
|
+
return { allowed: false, reason: 'cd only supports a single target path in safe mode' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const rawTarget = String(tokens[1] || '').trim();
|
|
227
|
+
if (!rawTarget || rawTarget.startsWith('-')) {
|
|
228
|
+
return { allowed: false, reason: 'cd target is not allowed in safe mode' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const resolvedTarget = path.resolve(path.resolve(workspaceRoot), rawTarget);
|
|
232
|
+
if (!isWithinAnyRoot(resolvedTarget, allowedPathRoots(workspaceRoot, config))) {
|
|
233
|
+
return { allowed: false, reason: `cd escapes workspace or allowed paths: ${rawTarget}` };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { allowed: true };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function evaluateCommandPolicy(command, config, workspaceRoot = process.cwd()) {
|
|
240
|
+
const policy = getEffectivePolicy(config);
|
|
241
|
+
const cmd = String(command || '').trim();
|
|
242
|
+
const lower = cmd.toLowerCase();
|
|
243
|
+
if (!cmd) {
|
|
244
|
+
return { allowed: false, reason: 'empty command' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!policy.allow_dangerous_commands && includesAny(lower, policy.blocked_command_patterns)) {
|
|
248
|
+
return { allowed: false, reason: 'blocked by dangerous command pattern' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!policy.safe_mode) {
|
|
252
|
+
return { allowed: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (includesAny(lower, policy.blocked_path_patterns)) {
|
|
256
|
+
return { allowed: false, reason: 'blocked protected system path' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const token = firstToken(cmd);
|
|
260
|
+
const inspectedTokens = collectCommandTokens(cmd);
|
|
261
|
+
const allowlist = Array.isArray(policy.command_allowlist) ? policy.command_allowlist : [];
|
|
262
|
+
for (const item of inspectedTokens) {
|
|
263
|
+
if (SHELL_KEYWORDS.has(item.token)) continue;
|
|
264
|
+
if (item.token === 'cd') {
|
|
265
|
+
const cdCheck = validateCdSegment(item.raw, workspaceRoot, config);
|
|
266
|
+
if (!cdCheck.allowed) {
|
|
267
|
+
return { allowed: false, reason: cdCheck.reason, suggestion: suggestionForToken(item.token, config) };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (includesAny(item.token, policy.blocked_commands)) {
|
|
271
|
+
return { allowed: false, reason: `blocked command: ${item.token}`, suggestion: suggestionForToken(item.token, config) };
|
|
272
|
+
}
|
|
273
|
+
if (allowlist.length > 0 && !allowlist.includes(item.token)) {
|
|
274
|
+
return {
|
|
275
|
+
allowed: false,
|
|
276
|
+
reason: `command not in allowlist: ${item.token}`,
|
|
277
|
+
suggestion: suggestionForToken(item.token, config)
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const allowedLower = allowedPathRoots(workspaceRoot, config).map((item) => item.toLowerCase().replace(/\//g, '\\'));
|
|
283
|
+
const windowsAbsPath = lower.match(/[a-z]:\\[^\s'"]+/g) || [];
|
|
284
|
+
for (const p of windowsAbsPath) {
|
|
285
|
+
if (!allowedLower.some((root) => p === root || p.startsWith(`${root}\\`))) {
|
|
286
|
+
return { allowed: false, reason: `absolute path outside workspace or allowed paths: ${p}`, suggestion: suggestionForToken(token, config) };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const posixAbsPath = cmd.match(/(?<![:/\w])\/(?!\/)[^\s'"]+/g) || [];
|
|
291
|
+
const allowedResolved = allowedPathRoots(workspaceRoot, config);
|
|
292
|
+
for (const p of posixAbsPath) {
|
|
293
|
+
if (!isWithinAnyRoot(p, allowedResolved)) {
|
|
294
|
+
return { allowed: false, reason: `absolute path outside workspace or allowed paths: ${p}`, suggestion: suggestionForToken(token, config) };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { allowed: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export { collectCommandTokens, firstToken };
|