codemini-cli 0.5.10 → 0.5.12

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.
Files changed (59) hide show
  1. package/OPERATIONS.md +242 -242
  2. package/README.md +588 -588
  3. package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-7HL7yft8.js → highlighted-body-OFNGDK62-B-G99D0A.js} +1 -1
  4. package/codemini-web/dist/assets/{index-BK75hMb2.js → index-DIGUEzan.js} +108 -108
  5. package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
  6. package/codemini-web/dist/assets/mermaid-GHXKKRXX-va2Kl89u.js +1 -0
  7. package/codemini-web/dist/index.html +35 -23
  8. package/codemini-web/lib/approval-manager.js +32 -32
  9. package/codemini-web/lib/runtime-bridge.js +17 -11
  10. package/codemini-web/server.js +534 -205
  11. package/deployment.md +212 -212
  12. package/package.json +2 -2
  13. package/skills/brainstorm/SKILL.md +77 -77
  14. package/skills/codemini.skills.json +40 -40
  15. package/skills/grill-me/SKILL.md +30 -30
  16. package/skills/superpowers-lite/SKILL.md +82 -82
  17. package/src/cli.js +74 -74
  18. package/src/commands/chat.js +210 -210
  19. package/src/commands/run.js +313 -313
  20. package/src/commands/skill.js +438 -304
  21. package/src/commands/web.js +57 -57
  22. package/src/core/agent-loop.js +980 -980
  23. package/src/core/ast.js +309 -307
  24. package/src/core/chat-runtime.js +6261 -6253
  25. package/src/core/command-evaluator.js +72 -72
  26. package/src/core/command-loader.js +311 -311
  27. package/src/core/command-policy.js +301 -301
  28. package/src/core/command-risk.js +156 -156
  29. package/src/core/config-store.js +286 -285
  30. package/src/core/constants.js +18 -1
  31. package/src/core/context-compact.js +365 -365
  32. package/src/core/default-system-prompt.js +114 -107
  33. package/src/core/dream-audit.js +105 -105
  34. package/src/core/dream-consolidate.js +229 -229
  35. package/src/core/dream-evaluator.js +185 -185
  36. package/src/core/fff-adapter.js +383 -383
  37. package/src/core/memory-store.js +543 -543
  38. package/src/core/project-index.js +737 -548
  39. package/src/core/project-instructions.js +98 -98
  40. package/src/core/provider/anthropic.js +514 -514
  41. package/src/core/provider/openai-compatible.js +501 -501
  42. package/src/core/reflect-skill.js +178 -178
  43. package/src/core/reply-language.js +40 -40
  44. package/src/core/session-store.js +474 -474
  45. package/src/core/shell-profile.js +237 -237
  46. package/src/core/shell.js +323 -323
  47. package/src/core/soul.js +69 -69
  48. package/src/core/system-prompt-composer.js +52 -52
  49. package/src/core/tool-args.js +199 -154
  50. package/src/core/tool-output.js +184 -184
  51. package/src/core/tool-result-store.js +206 -206
  52. package/src/core/tools.js +3024 -2893
  53. package/src/core/version.js +11 -11
  54. package/src/tui/chat-app.js +5173 -5171
  55. package/src/tui/tool-activity/presenters/misc.js +30 -30
  56. package/src/tui/tool-activity/presenters/system.js +20 -20
  57. package/templates/project-requirements/report-shell.html +582 -582
  58. package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
  59. 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 };