@axplusb/kepler 1.0.10 → 2.0.0
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/package.json +5 -2
- package/src/context/retriever.mjs +42 -4
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/approval.mjs +154 -95
- package/src/core/backend-url.mjs +2 -2
- package/src/core/headless.mjs +5 -0
- package/src/core/risk-tier.mjs +239 -0
- package/src/core/tool-executor.mjs +49 -3
- package/src/onboarding/preflight.mjs +274 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/terminal/ansi.mjs +44 -22
- package/src/terminal/repl.mjs +395 -108
- package/src/ui/approval.mjs +167 -0
- package/src/ui/banner.mjs +133 -122
- package/src/ui/dock.mjs +88 -0
- package/src/ui/icons.mjs +164 -0
- package/src/ui/mission-report.mjs +264 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/status-bar.mjs +275 -0
- package/src/ui/sub-agent.mjs +152 -0
- package/src/ui/term.mjs +159 -0
- package/src/ui/tool-card.mjs +314 -0
- package/src/ui/tool-details.mjs +277 -0
package/src/core/approval.mjs
CHANGED
|
@@ -11,45 +11,42 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { toolDisplayLabel, toolDisplaySummary } from '../terminal/tool-display.mjs';
|
|
14
|
+
import {
|
|
15
|
+
classify as classifyTier,
|
|
16
|
+
TIERS,
|
|
17
|
+
requiresExplicitApproval,
|
|
18
|
+
requiresCheckpoint,
|
|
19
|
+
label as tierLabel,
|
|
20
|
+
} from './risk-tier.mjs';
|
|
21
|
+
import {
|
|
22
|
+
renderApprovalPrompt,
|
|
23
|
+
renderInlinePrompt,
|
|
24
|
+
defaultOptions as approvalOptions,
|
|
25
|
+
} from '../ui/approval.mjs';
|
|
14
26
|
|
|
15
27
|
// ── Tool Classification ──
|
|
28
|
+
//
|
|
29
|
+
// Risk tiering moved to src/core/risk-tier.mjs (PRD-055 §8.1). WRITE_TOOLS
|
|
30
|
+
// stays here only because `planMode` blocks anything that writes.
|
|
16
31
|
|
|
17
32
|
const WRITE_TOOLS = new Set([
|
|
18
33
|
'shell', 'write_file', 'write_project', 'edit_file', 'delete_file',
|
|
19
34
|
'validate_build', 'lint_check',
|
|
20
35
|
]);
|
|
21
36
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
search_files: 'none', list_files: 'none', get_file_info: 'none',
|
|
36
|
-
validate_file: 'none', validate_structure: 'none',
|
|
37
|
-
write_file: 'low', write_project: 'low', edit_file: 'low',
|
|
38
|
-
lint_check: 'low', validate_build: 'medium',
|
|
39
|
-
shell: 'medium',
|
|
40
|
-
delete_file: 'high',
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
function assessShellRisk(command) {
|
|
44
|
-
if (!command) return 'medium';
|
|
45
|
-
if (/rm\s+-r/i.test(command)) return 'high';
|
|
46
|
-
if (/git\s+(push|reset|clean|checkout\s+\.)/i.test(command)) return 'high';
|
|
47
|
-
if (/drop\s+(table|database)/i.test(command)) return 'high';
|
|
48
|
-
if (/sudo\s/i.test(command)) return 'high';
|
|
49
|
-
if (/^(ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)/i.test(command)) return 'low';
|
|
50
|
-
if (/^git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|blame|shortlog|describe|rev-parse|ls-files|ls-tree)/i.test(command)) return 'low';
|
|
51
|
-
if (/^(npm\s+(test|run|list|ls|view|info|outdated|audit)|node\s+--check|python3?\s+-m\s+py_compile|cargo\s+(check|test|clippy))/i.test(command)) return 'low';
|
|
52
|
-
return 'medium';
|
|
37
|
+
function defaultWhy(tier, tool, args) {
|
|
38
|
+
switch (tier) {
|
|
39
|
+
case TIERS.SHELL_DANGEROUS:
|
|
40
|
+
return `Shell command matches a high-risk pattern (rm -rf, sudo, force push, etc.). Confirm before running.`;
|
|
41
|
+
case TIERS.DESTRUCTIVE:
|
|
42
|
+
return `${tool} permanently mutates project state. Confirm before running.`;
|
|
43
|
+
case TIERS.SHELL_MEDIUM:
|
|
44
|
+
return `Mutates the workspace or environment (install, build, commit, push).`;
|
|
45
|
+
case TIERS.NETWORK:
|
|
46
|
+
return `Reaches an external network endpoint.`;
|
|
47
|
+
default:
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
53
50
|
}
|
|
54
51
|
|
|
55
52
|
// ── ANSI helpers ──
|
|
@@ -107,93 +104,146 @@ export class ApprovalManager {
|
|
|
107
104
|
// Auto-approve everything in headless/autoApprove mode (no TTY prompts)
|
|
108
105
|
if (this.autoApprove) {
|
|
109
106
|
this.history.push({ tool: toolName, decision: 'auto', time: Date.now() });
|
|
110
|
-
return { approved: true };
|
|
107
|
+
return { approved: true, tier: classifyTier(toolName, args) };
|
|
111
108
|
}
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
|
|
110
|
+
const tier = classifyTier(toolName, args);
|
|
111
|
+
|
|
112
|
+
// 'auto' tiers: read, shell-safe.
|
|
113
|
+
if (tier === TIERS.READ || tier === TIERS.SHELL_SAFE) {
|
|
114
|
+
this.history.push({ tool: toolName, decision: 'auto-tier', tier, time: Date.now() });
|
|
115
|
+
return { approved: true, tier };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 'auto-with-undo' tier: local-edit. Checkpoint is taken by the tool
|
|
119
|
+
// executor before the edit; here we just approve.
|
|
120
|
+
if (tier === TIERS.LOCAL_EDIT) {
|
|
121
|
+
this.history.push({ tool: toolName, decision: 'auto-with-undo', tier, time: Date.now() });
|
|
122
|
+
return { approved: true, tier, requireCheckpoint: true };
|
|
114
123
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
|
|
125
|
+
// Honor approve-all / type-allow shortcuts for non-explicit tiers only.
|
|
126
|
+
if (!requiresExplicitApproval(tier)) {
|
|
127
|
+
if (this.approveAll) {
|
|
128
|
+
this.history.push({ tool: toolName, decision: 'auto-all', tier, time: Date.now() });
|
|
129
|
+
return { approved: true, tier };
|
|
120
130
|
}
|
|
121
|
-
if (
|
|
122
|
-
|
|
131
|
+
if (this.approvedToolTypes.has(toolName)) {
|
|
132
|
+
this.history.push({ tool: toolName, decision: 'type-auto', tier, time: Date.now() });
|
|
133
|
+
return { approved: true, tier };
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
if (this.approveAll) {
|
|
129
|
-
this.history.push({ tool: toolName, decision: 'auto', time: Date.now() });
|
|
130
|
-
return { approved: true };
|
|
131
|
-
}
|
|
132
|
-
if (this.approvedToolTypes.has(toolName)) {
|
|
133
|
-
this.history.push({ tool: toolName, decision: 'type-auto', time: Date.now() });
|
|
134
|
-
return { approved: true };
|
|
135
|
-
}
|
|
136
|
-
return this._prompt(toolName, args, context);
|
|
136
|
+
|
|
137
|
+
return this._prompt(toolName, args, { ...context, tier });
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
async _prompt(toolName, args, context = {}) {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const label = toolDisplayLabel(toolName);
|
|
141
|
+
const tier = context.tier || classifyTier(toolName, args);
|
|
142
|
+
const explicit = requiresExplicitApproval(tier);
|
|
143
|
+
const why = context.reason || context.why || defaultWhy(tier, toolName, args);
|
|
144
144
|
const summary = toolDisplaySummary(toolName, args);
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
write(
|
|
155
|
-
|
|
156
|
-
|
|
145
|
+
const options = approvalOptions(tier);
|
|
146
|
+
|
|
147
|
+
let selected = 0; // arrow-driven cursor
|
|
148
|
+
let printedHeight = 0;
|
|
149
|
+
|
|
150
|
+
// For TTYs we redraw in place on every arrow key so the prompt feels
|
|
151
|
+
// live. For non-TTYs / pipes we just print once and read a line.
|
|
152
|
+
const isInteractive = process.stdin.isTTY;
|
|
153
|
+
if (!isInteractive) {
|
|
154
|
+
write(explicit
|
|
155
|
+
? renderApprovalPrompt({ tool: toolName, args, tier, why, selected, options }) + '\n'
|
|
156
|
+
: renderInlinePrompt({ tool: toolName, args, tier, why }) + '\n');
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
const
|
|
159
|
+
const drawExplicit = () => {
|
|
160
|
+
// Move up over the previous render before re-printing.
|
|
161
|
+
if (printedHeight > 0) {
|
|
162
|
+
write(`\x1b[${printedHeight}F`); // cursor to start of N lines above
|
|
163
|
+
write('\x1b[J'); // clear from cursor to end of screen
|
|
164
|
+
}
|
|
165
|
+
const block = renderApprovalPrompt({ tool: toolName, args, tier, why, selected, options });
|
|
166
|
+
write(block + '\n');
|
|
167
|
+
printedHeight = block.split('\n').length;
|
|
168
|
+
};
|
|
160
169
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
170
|
+
if (isInteractive && explicit) drawExplicit();
|
|
171
|
+
if (isInteractive && !explicit) write(renderInlinePrompt({ tool: toolName, args, tier, why }) + '\n');
|
|
172
|
+
|
|
173
|
+
// ── Input loop ─────────────────────────────────────────────────
|
|
174
|
+
const choose = async () => {
|
|
175
|
+
for (;;) {
|
|
176
|
+
const k = await this._readKey();
|
|
177
|
+
|
|
178
|
+
if (k === 'up' || k === 'left') {
|
|
179
|
+
if (!explicit || !isInteractive) continue;
|
|
180
|
+
selected = (selected - 1 + options.length) % options.length;
|
|
181
|
+
drawExplicit();
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (k === 'down' || k === 'right' || k === 'tab') {
|
|
185
|
+
if (!explicit || !isInteractive) continue;
|
|
186
|
+
selected = (selected + 1) % options.length;
|
|
187
|
+
drawExplicit();
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (k === 'return') {
|
|
191
|
+
return options[selected].value;
|
|
192
|
+
}
|
|
193
|
+
if (k === 'escape') return 'reject';
|
|
194
|
+
|
|
195
|
+
// Letter shortcut: match against option.key
|
|
196
|
+
if (typeof k === 'string' && k.length === 1) {
|
|
197
|
+
const lower = k.toLowerCase();
|
|
198
|
+
const idx = options.findIndex(o => o.key === lower);
|
|
199
|
+
if (idx >= 0) {
|
|
200
|
+
selected = idx;
|
|
201
|
+
if (isInteractive && explicit) drawExplicit();
|
|
202
|
+
return options[idx].value;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Anything else: ignore and re-read.
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const value = await choose();
|
|
210
|
+
|
|
211
|
+
switch (value) {
|
|
212
|
+
case 'approve':
|
|
165
213
|
write(` ${GREEN}✓${RST} ${DIM}${toolName}${RST} ${DIM}${summary.slice(0, 60)}${RST}\n\n`);
|
|
166
|
-
this.history.push({ tool: toolName, decision: 'yes', time: Date.now() });
|
|
167
|
-
return { approved: true };
|
|
214
|
+
this.history.push({ tool: toolName, decision: 'yes', tier, time: Date.now() });
|
|
215
|
+
return { approved: true, tier };
|
|
168
216
|
|
|
169
|
-
case '
|
|
170
|
-
case 'N':
|
|
171
|
-
case 'escape':
|
|
217
|
+
case 'reject':
|
|
172
218
|
write(` ${RED}✗${RST} ${DIM}denied${RST}\n\n`);
|
|
173
|
-
this.history.push({ tool: toolName, decision: 'no', time: Date.now() });
|
|
174
|
-
return { approved: false, reason: 'User denied' };
|
|
219
|
+
this.history.push({ tool: toolName, decision: 'no', tier, time: Date.now() });
|
|
220
|
+
return { approved: false, tier, reason: 'User denied' };
|
|
175
221
|
|
|
176
|
-
case '
|
|
177
|
-
|
|
178
|
-
if (isDestructive) return this._prompt(toolName, args, context);
|
|
222
|
+
case 'allow-all':
|
|
223
|
+
if (explicit) return this._prompt(toolName, args, context);
|
|
179
224
|
this.approveAll = true;
|
|
180
225
|
write(` ${GREEN}✓✓${RST} ${DIM}allow-all activated${RST}\n\n`);
|
|
181
|
-
this.history.push({ tool: toolName, decision: 'approve-all', time: Date.now() });
|
|
182
|
-
return { approved: true };
|
|
226
|
+
this.history.push({ tool: toolName, decision: 'approve-all', tier, time: Date.now() });
|
|
227
|
+
return { approved: true, tier };
|
|
183
228
|
|
|
184
|
-
case '
|
|
185
|
-
|
|
186
|
-
if (isDestructive) return this._prompt(toolName, args, context);
|
|
229
|
+
case 'allow-type':
|
|
230
|
+
if (explicit) return this._prompt(toolName, args, context);
|
|
187
231
|
this.approvedToolTypes.add(toolName);
|
|
188
232
|
write(` ${GREEN}✓${RST} ${DIM}always allow ${toolName}${RST}\n\n`);
|
|
189
|
-
this.history.push({ tool: toolName, decision: 'type-approve', time: Date.now() });
|
|
190
|
-
return { approved: true };
|
|
233
|
+
this.history.push({ tool: toolName, decision: 'type-approve', tier, time: Date.now() });
|
|
234
|
+
return { approved: true, tier };
|
|
191
235
|
|
|
192
|
-
case '
|
|
193
|
-
|
|
194
|
-
|
|
236
|
+
case 'why':
|
|
237
|
+
write(`\n ${DIM}${(context.reason || why).slice(0, 400)}${RST}\n\n`);
|
|
238
|
+
printedHeight = 0;
|
|
195
239
|
return this._prompt(toolName, args, context);
|
|
196
240
|
|
|
241
|
+
case 'edit':
|
|
242
|
+
case 'replan':
|
|
243
|
+
write(` ${YELLOW}↩${RST} ${DIM}reject with hint — rework the plan${RST}\n\n`);
|
|
244
|
+
this.history.push({ tool: toolName, decision: 'replan', tier, time: Date.now() });
|
|
245
|
+
return { approved: false, tier, reason: 'User asked to re-plan' };
|
|
246
|
+
|
|
197
247
|
default:
|
|
198
248
|
return this._prompt(toolName, args, context);
|
|
199
249
|
}
|
|
@@ -221,7 +271,16 @@ export class ApprovalManager {
|
|
|
221
271
|
const str = data.toString();
|
|
222
272
|
|
|
223
273
|
if (bytes[0] === 0x03) process.exit(0);
|
|
224
|
-
|
|
274
|
+
// Arrow keys: ESC [ A/B/C/D (3-byte CSI sequences)
|
|
275
|
+
if (bytes.length === 3 && bytes[0] === 0x1b && bytes[1] === 0x5b) {
|
|
276
|
+
if (bytes[2] === 0x41) { resolve('up'); return; }
|
|
277
|
+
if (bytes[2] === 0x42) { resolve('down'); return; }
|
|
278
|
+
if (bytes[2] === 0x43) { resolve('right'); return; }
|
|
279
|
+
if (bytes[2] === 0x44) { resolve('left'); return; }
|
|
280
|
+
}
|
|
281
|
+
// Bare Esc (single byte) — explicit reject signal
|
|
282
|
+
if (bytes.length === 1 && bytes[0] === 0x1b) { resolve('escape'); return; }
|
|
283
|
+
if (bytes[0] === 0x09) { resolve('tab'); return; }
|
|
225
284
|
if (str === '\r' || str === '\n') { resolve('return'); return; }
|
|
226
285
|
resolve(str);
|
|
227
286
|
});
|
package/src/core/backend-url.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
const BACKEND_URLS = {
|
|
11
11
|
local: 'http://127.0.0.1:8000',
|
|
12
|
-
development: 'https://
|
|
13
|
-
production: 'https://
|
|
12
|
+
development: 'https://codekepler-backend-dev.kindisland-9034322d.eastus.azurecontainerapps.io',
|
|
13
|
+
production: 'https://codekepler-backend-prod.redsky-6d31f3e5.eastus.azurecontainerapps.io',
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
// Aliases
|
package/src/core/headless.mjs
CHANGED
|
@@ -188,6 +188,11 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
188
188
|
toolBreakdown[t.tool] = (toolBreakdown[t.tool] || 0) + 1;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Include sub-agent tool counts in the total
|
|
192
|
+
for (const sa of subAgents) {
|
|
193
|
+
toolCount += sa.tool_calls || 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
191
196
|
emit({
|
|
192
197
|
type: 'complete',
|
|
193
198
|
tools: toolCount,
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk tier classifier — Mission Control (PRD-055 §8.1).
|
|
3
|
+
*
|
|
4
|
+
* import { classify, TIERS, behavior } from './risk-tier.mjs';
|
|
5
|
+
* const tier = classify('shell', { command: 'rm -rf node_modules' });
|
|
6
|
+
* // → 'shell-dangerous'
|
|
7
|
+
*
|
|
8
|
+
* Pure: no I/O, no async. Tested in isolation; the rest of the CLI relies on
|
|
9
|
+
* the return value to decide whether to auto-approve, auto-approve with
|
|
10
|
+
* checkpoint, or hold for explicit approval.
|
|
11
|
+
*
|
|
12
|
+
* The CLI never asks the backend for the tier. The backend's job is to say
|
|
13
|
+
* "this is what I want to run"; we map that to a tier locally so dangerous
|
|
14
|
+
* intent can't be hidden behind a friendly description.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── Tier enum ────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export const TIERS = Object.freeze({
|
|
20
|
+
READ: 'read',
|
|
21
|
+
LOCAL_EDIT: 'local-edit',
|
|
22
|
+
SHELL_SAFE: 'shell-safe',
|
|
23
|
+
SHELL_MEDIUM: 'shell-medium',
|
|
24
|
+
SHELL_DANGEROUS: 'shell-dangerous',
|
|
25
|
+
DESTRUCTIVE: 'destructive',
|
|
26
|
+
NETWORK: 'network',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Default behavior by tier:
|
|
31
|
+
* auto — proceed silently
|
|
32
|
+
* auto-with-undo — proceed but record a checkpoint first
|
|
33
|
+
* prompt-safe — prompt with Enter=approve default
|
|
34
|
+
* prompt-explicit — magenta-bordered prompt, no default
|
|
35
|
+
*/
|
|
36
|
+
export const BEHAVIOR = Object.freeze({
|
|
37
|
+
[TIERS.READ]: 'auto',
|
|
38
|
+
[TIERS.LOCAL_EDIT]: 'auto-with-undo',
|
|
39
|
+
[TIERS.SHELL_SAFE]: 'auto',
|
|
40
|
+
[TIERS.SHELL_MEDIUM]: 'prompt-safe',
|
|
41
|
+
[TIERS.SHELL_DANGEROUS]: 'prompt-explicit',
|
|
42
|
+
[TIERS.DESTRUCTIVE]: 'prompt-explicit',
|
|
43
|
+
[TIERS.NETWORK]: 'prompt-safe',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export function behavior(tier) {
|
|
47
|
+
return BEHAVIOR[tier] || 'prompt-safe';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Tool → tier (non-shell) ─────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const READ_TOOLS = new Set([
|
|
53
|
+
'read_file', 'read_files',
|
|
54
|
+
'search_code', 'search_files', 'grep',
|
|
55
|
+
'list_files', 'get_file_info', 'get_project_overview',
|
|
56
|
+
'git_status', 'git_diff',
|
|
57
|
+
'analyze_code',
|
|
58
|
+
'validate_file', 'validate_structure',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const LOCAL_EDIT_TOOLS = new Set([
|
|
62
|
+
'edit_file', 'write_file', 'write_project',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const DESTRUCTIVE_TOOLS = new Set([
|
|
66
|
+
'delete_file',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const NETWORK_TOOLS = new Set([
|
|
70
|
+
'WebFetch', 'fetch_url',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// ── Shell sub-classifier ────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const SHELL_SAFE_RE = [
|
|
76
|
+
// Inspection / read-only
|
|
77
|
+
/^\s*(ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|fd|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)\b/i,
|
|
78
|
+
/^\s*git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|blame|shortlog|describe|rev-parse|ls-files|ls-tree|config\s+--get)\b/i,
|
|
79
|
+
// Test-only invocations
|
|
80
|
+
/^\s*(npm|pnpm|yarn)\s+(test|run\s+test|run\s+lint|list|ls|view|info|outdated)\b/i,
|
|
81
|
+
/^\s*node\s+--check\b/i,
|
|
82
|
+
/^\s*python3?\s+-m\s+py_compile\b/i,
|
|
83
|
+
/^\s*pytest\b(?!.*--?(delete|rm|destructive))/i,
|
|
84
|
+
/^\s*cargo\s+(check|test|clippy|build)\b/i,
|
|
85
|
+
/^\s*go\s+(test|vet|build)\b/i,
|
|
86
|
+
/^\s*make\s+(test|check|lint|build)\b/i,
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const SHELL_DANGEROUS_RE = [
|
|
90
|
+
/\brm\s+-r/i,
|
|
91
|
+
/\brm\s+--recursive/i,
|
|
92
|
+
/\brm\s+-rf?\b/i,
|
|
93
|
+
/\bunlink\s/i,
|
|
94
|
+
/\brmdir\s+-/i,
|
|
95
|
+
/\bgit\s+push.*--force/i,
|
|
96
|
+
/\bgit\s+push.*-f\b/i,
|
|
97
|
+
/\bgit\s+reset\s+--hard/i,
|
|
98
|
+
/\bgit\s+clean\s+-f/i,
|
|
99
|
+
/\bgit\s+checkout\s+\./i,
|
|
100
|
+
/\bgit\s+stash\s+drop/i,
|
|
101
|
+
/\bgit\s+branch\s+-D/i,
|
|
102
|
+
/\bgit\s+filter-branch/i,
|
|
103
|
+
/\bsudo\b/i,
|
|
104
|
+
/\bsu\s+-/i,
|
|
105
|
+
/\bcurl\b.*\|\s*(sh|bash|zsh)/i,
|
|
106
|
+
/\bwget\b.*\|\s*(sh|bash|zsh)/i,
|
|
107
|
+
/\beval\s+["'$(]/i,
|
|
108
|
+
/\bkubectl\s+delete/i,
|
|
109
|
+
/\bdocker\s+(rm|rmi|system\s+prune|volume\s+rm|network\s+rm)/i,
|
|
110
|
+
/\bdrop\s+(table|database|schema)/i,
|
|
111
|
+
/\btruncate\s+table/i,
|
|
112
|
+
/\bmkfs\b/i,
|
|
113
|
+
/\bdd\s+if=/i,
|
|
114
|
+
/:\s*\(\s*\)\s*\{.*:\|/i, // fork bomb
|
|
115
|
+
/>\s*\/dev\/sda/i,
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const SHELL_MEDIUM_RE = [
|
|
119
|
+
/^\s*(npm|pnpm|yarn)\s+(install|i|add|remove|uninstall|update|upgrade|publish|deploy)\b/i,
|
|
120
|
+
/^\s*pip\s+(install|uninstall|--upgrade)\b/i,
|
|
121
|
+
/^\s*pipx\s+(install|uninstall)\b/i,
|
|
122
|
+
/^\s*brew\s+(install|uninstall|upgrade|update)\b/i,
|
|
123
|
+
/^\s*apt(-get)?\s+(install|remove|upgrade|update)\b/i,
|
|
124
|
+
/^\s*cargo\s+(install|uninstall|publish|run)\b/i,
|
|
125
|
+
/^\s*go\s+(install|get|mod\s+tidy|mod\s+download)\b/i,
|
|
126
|
+
/^\s*make(\s|$)/i,
|
|
127
|
+
/^\s*git\s+(commit|push|pull|merge|rebase|fetch|checkout(?!\s+\.)|cherry-pick|revert|tag|stash(?!\s+drop))/i,
|
|
128
|
+
/^\s*docker\s+(build|run|exec|compose|pull|push|tag)/i,
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
export function classifyShell(command) {
|
|
132
|
+
const cmd = String(command || '').trim();
|
|
133
|
+
if (!cmd) return TIERS.SHELL_MEDIUM;
|
|
134
|
+
|
|
135
|
+
// Dangerous wins over safe — never let a safe-looking prefix mask `&& rm -rf`.
|
|
136
|
+
if (SHELL_DANGEROUS_RE.some(re => re.test(cmd))) return TIERS.SHELL_DANGEROUS;
|
|
137
|
+
|
|
138
|
+
// For a chained command, classify each segment and take the riskiest —
|
|
139
|
+
// never let a safe-looking prefix mask `&& npm install` or worse.
|
|
140
|
+
if (/&&|\|\||;|\|(?!\|)/.test(cmd)) {
|
|
141
|
+
const segments = splitShellSegments(cmd);
|
|
142
|
+
if (segments.length > 1) {
|
|
143
|
+
let worst = TIERS.SHELL_SAFE;
|
|
144
|
+
for (const seg of segments) {
|
|
145
|
+
const t = classifyShell(seg);
|
|
146
|
+
worst = riskier(worst, t);
|
|
147
|
+
if (worst === TIERS.SHELL_DANGEROUS) return worst;
|
|
148
|
+
}
|
|
149
|
+
return worst;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (SHELL_MEDIUM_RE.some(re => re.test(cmd))) return TIERS.SHELL_MEDIUM;
|
|
154
|
+
if (SHELL_SAFE_RE.some(re => re.test(cmd))) return TIERS.SHELL_SAFE;
|
|
155
|
+
return TIERS.SHELL_MEDIUM;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function splitShellSegments(cmd) {
|
|
159
|
+
// Split on top-level &&, ||, ;, | — naive but enough for the classifier.
|
|
160
|
+
return cmd.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const TIER_ORDER = [
|
|
164
|
+
TIERS.READ,
|
|
165
|
+
TIERS.SHELL_SAFE,
|
|
166
|
+
TIERS.LOCAL_EDIT,
|
|
167
|
+
TIERS.NETWORK,
|
|
168
|
+
TIERS.SHELL_MEDIUM,
|
|
169
|
+
TIERS.DESTRUCTIVE,
|
|
170
|
+
TIERS.SHELL_DANGEROUS,
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
function riskier(a, b) {
|
|
174
|
+
return TIER_ORDER.indexOf(b) > TIER_ORDER.indexOf(a) ? b : a;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Top-level classify ──────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Classify a tool call into a risk tier. Always returns one of TIERS.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} tool Tool name (e.g. 'shell', 'edit_file')
|
|
183
|
+
* @param {object} args Tool arguments (e.g. { command: 'rm -rf x' })
|
|
184
|
+
*/
|
|
185
|
+
export function classify(tool, args = {}) {
|
|
186
|
+
if (!tool) return TIERS.SHELL_MEDIUM;
|
|
187
|
+
|
|
188
|
+
if (READ_TOOLS.has(tool)) return TIERS.READ;
|
|
189
|
+
if (LOCAL_EDIT_TOOLS.has(tool)) return TIERS.LOCAL_EDIT;
|
|
190
|
+
if (DESTRUCTIVE_TOOLS.has(tool)) return TIERS.DESTRUCTIVE;
|
|
191
|
+
if (NETWORK_TOOLS.has(tool)) return TIERS.NETWORK;
|
|
192
|
+
|
|
193
|
+
if (tool === 'shell' || tool === 'run_tests' || tool === 'validate_build' || tool === 'lint_check') {
|
|
194
|
+
return classifyShell(args.command || args.cmd || '');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Sub-agents inherit their parent's risk (read-ish by default).
|
|
198
|
+
if (['explore', 'plan', 'verify', 'debug', 'refactor', 'analyze_code'].includes(tool)) {
|
|
199
|
+
return TIERS.READ;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// MCP tools: assume network unless the name implies read.
|
|
203
|
+
if (tool.startsWith('mcp')) {
|
|
204
|
+
return /(?:read|get|list|search|describe|info|status)/i.test(tool) ? TIERS.READ : TIERS.NETWORK;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return TIERS.SHELL_MEDIUM;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Convenience: human label for a tier (used in approval prompts and the
|
|
212
|
+
* status bar). Returned strings already uppercased / hyphenated.
|
|
213
|
+
*/
|
|
214
|
+
export function label(tier) {
|
|
215
|
+
switch (tier) {
|
|
216
|
+
case TIERS.READ: return 'READ';
|
|
217
|
+
case TIERS.LOCAL_EDIT: return 'LOCAL-EDIT';
|
|
218
|
+
case TIERS.SHELL_SAFE: return 'SHELL-SAFE';
|
|
219
|
+
case TIERS.SHELL_MEDIUM: return 'SHELL-MEDIUM';
|
|
220
|
+
case TIERS.SHELL_DANGEROUS: return 'SHELL-DANGEROUS';
|
|
221
|
+
case TIERS.DESTRUCTIVE: return 'DESTRUCTIVE';
|
|
222
|
+
case TIERS.NETWORK: return 'NETWORK';
|
|
223
|
+
default: return String(tier || '').toUpperCase();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether the tool needs an explicit human keystroke before running.
|
|
229
|
+
*/
|
|
230
|
+
export function requiresExplicitApproval(tier) {
|
|
231
|
+
return behavior(tier) === 'prompt-explicit';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Whether the tier should auto-create an undo checkpoint before running.
|
|
236
|
+
*/
|
|
237
|
+
export function requiresCheckpoint(tier) {
|
|
238
|
+
return behavior(tier) === 'auto-with-undo';
|
|
239
|
+
}
|