shoud-cli 1.0.11 → 3.0.2

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.
@@ -1,130 +1,313 @@
1
1
  const axios = require('axios');
2
2
  const chalk = require('chalk');
3
3
  const ora = require('ora');
4
- const open = require('open');
5
4
  const { getToken } = require('../auth/deviceFlow');
6
- const { verifyPermission } = require('../permissions/engine');
7
- const { executeTool } = require('../tools/index');
8
- const { saveCheckpoint, loadCheckpoint, clearCheckpoint } = require('../context/checkpoint');
9
-
10
- const API_URL = process.env.SHOUD_API_URL || 'https://shoud.online/api';
11
- const BILLING_URL = process.env.SHOUD_BILLING_URL || 'https://shoud.vantyrixtek.online';
12
-
13
- async function executeTask(prompt) {
14
- const token = getToken();
15
- if (!token) {
16
- console.log(chalk.red('Not signed in. Run `shoud login` first.'));
17
- return;
5
+ const { loadSettings } = require('../utils/config');
6
+ const { verifyPermission, createPermissionSession } = require('../security/permissions');
7
+ const { executeTool, toolList } = require('../tools');
8
+ const { redactDeep } = require('../security/secrets');
9
+ const { saveJob, appendAction, appendMessage } = require('../job/manager');
10
+ const { collectBaseline } = require('../job/baseline');
11
+ const { createCheckpoint, finalizeCheckpoint } = require('../job/checkpoint');
12
+ const { runVerification } = require('./verification');
13
+ const { createBudgetTracker, formatBudgetStatus } = require('./budget');
14
+ const { NoProgressDetector } = require('./noProgress');
15
+ const { JobStatus } = require('../job/model');
16
+ const { discoverProject, formatProject } = require('../project/discovery');
17
+ const { loadShoudMd } = require('../project/shoudMd');
18
+ const { formatReceipt } = require('../job/receipt');
19
+ const { ExitCodes } = require('../utils/errors');
20
+
21
+ /**
22
+ * Main agent loop. Orchestrates discovery → baseline → checkpoint → loop →
23
+ * verification → receipt.
24
+ */
25
+ async function executeTask(job) {
26
+ const settings = loadSettings();
27
+ const token = await getToken();
28
+ if (!token) throw Object.assign(new Error('Not signed in. Run `shoud login`.'), { code: ExitCodes.AUTH_REQUIRED });
29
+
30
+ const projectRoot = job.projectRoot;
31
+ const budget = createBudgetTracker(job.budget);
32
+ const session = createPermissionSession();
33
+ const noProgress = new NoProgressDetector();
34
+
35
+ // ── 1. Project discovery ──────────────────────────────────────
36
+ console.log(chalk.cyan('\n● Discovering project...'));
37
+ const project = discoverProject(projectRoot);
38
+ job.project = project;
39
+ console.log(chalk.gray(formatProject(project)));
40
+
41
+ // ── 2. Load SHOUD.md ─────────────────────────────────────────
42
+ const shoud = loadShoudMd(projectRoot);
43
+ if (shoud.source) console.log(chalk.gray(`\n Loaded ${shoud.source}`));
44
+
45
+ // Combine verification commands: SHOUD.md overrides discovery
46
+ const verificationCommands = (shoud.verification.length ? shoud.verification : project.verification) || [];
47
+ job.verification.commands = verificationCommands;
48
+
49
+ // ── 3. Baseline ───────────────────────────────────────────────
50
+ if (verificationCommands.length) {
51
+ console.log(chalk.cyan('\n● Establishing baseline...'));
52
+ const baseline = await collectBaseline(project, verificationCommands, {
53
+ onProgress: (cmd) => process.stdout.write(chalk.gray(` → ${cmd}\n`)),
54
+ });
55
+ job.baseline = baseline;
56
+ if (baseline.git.dirty) {
57
+ console.log(chalk.yellow(`\n ⚠ ${baseline.git.modifiedFiles.length} pre-existing modified file(s) will be preserved.`));
58
+ }
18
59
  }
19
60
 
20
- let messages = [];
21
- const existingState = loadCheckpoint();
61
+ // ── 4. Checkpoint ─────────────────────────────────────────────
62
+ const cp = createCheckpoint(job.id, projectRoot);
63
+ job.checkpoint = cp;
64
+ console.log(chalk.gray(`\n Checkpoint saved (undo with: shoud undo ${job.id})`));
22
65
 
23
- if (existingState) {
24
- console.log(chalk.cyan('● Restoring task context from checkpoint...'));
25
- messages = existingState.messages;
26
- } else {
27
- messages.push({ role: 'user', content: prompt });
66
+ // ── 5. Build the system prompt ────────────────────────────────
67
+ const systemPrompt = buildSystemPrompt(project, shoud, verificationCommands);
68
+
69
+ // ── 6. Initial message ────────────────────────────────────────
70
+ if (!job.messages.length) {
71
+ job.messages.push({ role: 'user', content: job.prompt });
28
72
  }
29
73
 
74
+ job.status = JobStatus.RUNNING;
75
+ saveJob(job);
76
+
77
+ // ── 7. Agent loop ─────────────────────────────────────────────
30
78
  let taskComplete = false;
31
- const spinner = ora('SHOUD is thinking...').start();
79
+ let finalText = '';
80
+ const MAX_ITERATIONS = 50;
81
+ let iteration = 0;
82
+
83
+ while (!taskComplete && iteration < MAX_ITERATIONS) {
84
+ iteration++;
85
+ const spinner = ora('SHOUD is thinking...').start();
32
86
 
33
- while (!taskComplete) {
87
+ let response;
34
88
  try {
35
- const response = await axios.post(`${API_URL}/agent/infer`, {
36
- messages,
37
- systemPrompt: undefined // optional, can be passed via CLI flag if needed
89
+ response = await axios.post(`${settings.apiUrl}/agent/infer`, {
90
+ messages: redactDeep(job.messages),
91
+ systemPrompt,
92
+ tools: toolList(),
38
93
  }, {
39
- headers: { Authorization: `Bearer ${token}` }
94
+ headers: { Authorization: `Bearer ${token}` },
95
+ timeout: 120000,
40
96
  });
97
+ } catch (err) {
98
+ spinner.stop();
99
+ if (err.response?.status === 402) {
100
+ job.status = JobStatus.BUDGET_EXCEEDED;
101
+ job.completedAt = new Date().toISOString();
102
+ finalizeCheckpoint(job.id, projectRoot);
103
+ saveJob(job);
104
+ console.log(chalk.red('\n⏸ Credits exhausted. Job checkpointed.'));
105
+ console.log(chalk.gray('Run your command again after adding credits.'));
106
+ return job;
107
+ }
108
+ if (err.response?.status === 401) {
109
+ spinner.stop();
110
+ throw Object.assign(new Error('Session expired. Run `shoud login`.'), { code: ExitCodes.AUTH_REQUIRED });
111
+ }
112
+ throw err;
113
+ }
114
+ spinner.stop();
41
115
 
42
- const aiMessage = response.data;
116
+ // Cost estimate (backend should return this; fallback to a heuristic)
117
+ const cost = response.data.cost ?? estimateCost(response.data);
118
+ budget.add(cost);
119
+ job.spent = budget.spent;
43
120
 
44
- // Validate response structure
45
- if (!aiMessage || !aiMessage.content || !Array.isArray(aiMessage.content)) {
46
- throw new Error('Invalid response from SHOUD agent.');
47
- }
121
+ const aiMessage = response.data;
122
+ if (!aiMessage?.content || !Array.isArray(aiMessage.content)) {
123
+ throw new Error('Invalid response from SHOUD agent.');
124
+ }
48
125
 
49
- // Add assistant message to conversation history
50
- messages.push({ role: 'assistant', content: aiMessage.content });
51
- spinner.stop();
126
+ job.messages.push({ role: 'assistant', content: aiMessage.content });
127
+ saveJob(job);
128
+
129
+ // Budget check
130
+ const status = budget.status();
131
+ if (status.warn) {
132
+ console.log(chalk.yellow(`\n Budget: ${formatBudgetStatus(status)}`));
133
+ }
134
+ if (status.exceeded) {
135
+ job.status = JobStatus.BUDGET_EXCEEDED;
136
+ job.completedAt = new Date().toISOString();
137
+ finalizeCheckpoint(job.id, projectRoot);
138
+ saveJob(job);
139
+ console.log(chalk.red('\n⏸ Budget exhausted. Job checkpointed.'));
140
+ return job;
141
+ }
142
+
143
+ if (aiMessage.stop_reason === 'tool_use') {
144
+ const toolResults = [];
145
+ for (const block of aiMessage.content) {
146
+ if (block.type !== 'tool_use') continue;
147
+
148
+ console.log(chalk.blue(`\n● ${block.name}`));
149
+ const permission = await verifyPermission(session, block.name, block.input);
52
150
 
53
- // Check if the assistant used a tool
54
- if (aiMessage.stop_reason === 'tool_use') {
55
- for (const block of aiMessage.content) {
56
- if (block.type === 'tool_use') {
57
- console.log(chalk.blue(`\n● Agent requested: ${block.name}`));
58
-
59
- const isAllowed = await verifyPermission(block.name, block.input);
60
- let toolResult = '';
61
-
62
- if (isAllowed) {
63
- spinner.start(`Executing ${block.name}...`);
64
- try {
65
- toolResult = executeTool(block.name, block.input);
66
- spinner.stop();
67
- console.log(chalk.green(`✓ Tool executed.`));
68
- } catch (toolError) {
69
- spinner.stop();
70
- console.log(chalk.red(`✕ Tool execution failed: ${toolError.message}`));
71
- toolResult = `Error: ${toolError.message}`;
72
- }
73
- } else {
74
- toolResult = 'User denied permission to execute this tool.';
75
- console.log(chalk.red(`✕ Permission denied.`));
76
- }
77
-
78
- // Send tool result back to the agent
79
- messages.push({
80
- role: 'user',
81
- content: [{ type: 'tool_result', tool_use_id: block.id, content: toolResult }]
82
- });
83
-
84
- // Save checkpoint after each tool interaction
85
- saveCheckpoint({ messages });
151
+ let result;
152
+ if (!permission.allowed) {
153
+ result = { ok: false, error: `Permission denied: ${permission.reason}` };
154
+ console.log(chalk.red(` ✕ ${permission.reason}`));
155
+ } else {
156
+ const ctx = {
157
+ projectRoot,
158
+ onOutput: (chunk) => process.stdout.write(chalk.gray(chunk)),
159
+ };
160
+ try {
161
+ result = await executeTool(block.name, ctx, block.input);
162
+ console.log(chalk.green(` ✓ done`));
163
+ } catch (e) {
164
+ result = { ok: false, error: e.message };
165
+ console.log(chalk.red(` ✕ ${e.message}`));
86
166
  }
87
167
  }
88
- spinner.start('Analyzing tool results...');
89
- } else {
90
- // Task completed (no more tool calls)
91
- taskComplete = true;
92
- clearCheckpoint();
93
168
 
94
- // Extract the final text response
95
- const textBlocks = aiMessage.content.filter(c => c.type === 'text');
96
- const finalResponse = textBlocks.length > 0
97
- ? textBlocks.map(t => t.text).join('\n')
98
- : 'Task completed.';
169
+ noProgress.recordAction({}, block.name, block.input);
170
+ noProgress.recordResult(block.name, block.input, result);
171
+
172
+ // Track changed files
173
+ if (['write_file', 'apply_patch'].includes(block.name) && block.input.path) {
174
+ if (!job.changedFiles.includes(block.input.path)) {
175
+ job.changedFiles.push(block.input.path);
176
+ }
177
+ }
99
178
 
100
- console.log(chalk.green('\n✓ Task Complete.\n'));
101
- console.log(chalk.white(finalResponse));
179
+ appendAction(job.id, {
180
+ at: new Date().toISOString(),
181
+ tool: block.name,
182
+ input: redactDeep(block.input),
183
+ ok: result?.ok !== false,
184
+ permission: permission.reason,
185
+ });
186
+
187
+ const toolResult = {
188
+ type: 'tool_result',
189
+ tool_use_id: block.id,
190
+ content: serializeResult(block.name, result),
191
+ is_error: result?.ok === false,
192
+ };
193
+ toolResults.push(toolResult);
102
194
  }
103
195
 
104
- } catch (error) {
105
- spinner.stop();
196
+ job.messages.push({ role: 'user', content: toolResults });
197
+ saveJob(job);
106
198
 
107
- // Handle insufficient funds (402) specifically
108
- if (error.response && error.response.status === 402) {
109
- const balance = error.response.data.balance || 0;
110
- console.log(chalk.red('\n⏸ Task paused. Insufficient SHOUD Credits.'));
111
- console.log(chalk.gray(`Balance: $${balance.toFixed(4)}`));
112
- // Save checkpoint so the user can resume later
113
- saveCheckpoint({ messages });
114
-
115
- console.log(chalk.cyan('Opening browser to add credits...'));
116
- await open(`${BILLING_URL}/?amount=10`);
117
- console.log(chalk.gray('Run your command again once credits are added to resume.'));
118
- return;
199
+ // No-progress detection
200
+ const stuck = noProgress.isStuck();
201
+ if (stuck) {
202
+ job.status = JobStatus.BLOCKED;
203
+ job.completedAt = new Date().toISOString();
204
+ job.error = `No progress detected: ${stuck.reason}`;
205
+ finalizeCheckpoint(job.id, projectRoot);
206
+ saveJob(job);
207
+ console.log(chalk.yellow(`\n■ SHOUD is blocked: ${stuck.reason}.`));
208
+ console.log(chalk.gray('No further changes will be attempted.'));
209
+ return job;
119
210
  }
211
+ } else {
212
+ // Agent claims completion. We do NOT trust this. Run verification.
213
+ finalText = aiMessage.content
214
+ .filter(c => c.type === 'text')
215
+ .map(t => t.text).join('\n');
216
+
217
+ // ── 8. Verification ──────────────────────────────────────
218
+ if (verificationCommands.length) {
219
+ console.log(chalk.cyan('\n● Agent claims completion. Verifying...'));
220
+ job.status = JobStatus.VERIFYING;
221
+ saveJob(job);
222
+
223
+ const results = await runVerification(projectRoot, verificationCommands, {
224
+ onOutput: (chunk) => process.stdout.write(chalk.gray(chunk)),
225
+ });
226
+ job.verification.results = results;
227
+
228
+ const allPassed = results.every(r => r.ok);
120
229
 
121
- // Generic error
122
- console.log(chalk.red(`\nError: ${error.response?.data?.error || error.message}`));
123
- // Save checkpoint to preserve progress
124
- saveCheckpoint({ messages });
125
- return;
230
+ if (allPassed) {
231
+ job.status = JobStatus.VERIFIED;
232
+ taskComplete = true;
233
+ } else {
234
+ // Feed failures back to agent
235
+ const failures = results.filter(r => !r.ok)
236
+ .map(r => `Command "${r.command}" failed (exit ${r.exitCode}):\n${r.summary}`)
237
+ .join('\n\n');
238
+ job.messages.push({
239
+ role: 'user',
240
+ content: [{
241
+ type: 'text',
242
+ text: `Verification failed. You claimed completion but the following checks did not pass. Fix the root cause and try again.\n\n${failures}`,
243
+ }],
244
+ });
245
+ job.status = JobStatus.RUNNING;
246
+ saveJob(job);
247
+ }
248
+ } else {
249
+ // No verification commands → IMPLEMENTED_NOT_VERIFIED
250
+ job.status = JobStatus.IMPLEMENTED_NOT_VERIFIED;
251
+ taskComplete = true;
252
+ }
126
253
  }
127
254
  }
255
+
256
+ if (iteration >= MAX_ITERATIONS) {
257
+ job.status = JobStatus.BLOCKED;
258
+ job.error = 'Max iterations reached without completion.';
259
+ }
260
+
261
+ // ── 9. Finalize ───────────────────────────────────────────────
262
+ job.completedAt = new Date().toISOString();
263
+ finalizeCheckpoint(job.id, projectRoot);
264
+ job.completion = { finalText };
265
+ saveJob(job);
266
+
267
+ // ── 10. Receipt ───────────────────────────────────────────────
268
+ console.log('\n' + formatReceipt(job));
269
+
270
+ return job;
271
+ }
272
+
273
+ function buildSystemPrompt(project, shoud, verificationCommands) {
274
+ return `You are SHOUD, an autonomous engineering agent operating on a local project.
275
+
276
+ PROJECT
277
+ Root: ${project.root}
278
+ Language: ${project.language || 'unknown'}
279
+ Framework: ${project.framework || '—'}
280
+ Package mgr: ${project.packageManager || '—'}
281
+ Testing: ${project.testing || '—'}
282
+ Git: ${project.git ? 'yes' : 'no'}
283
+
284
+ VERIFICATION COMMANDS
285
+ ${verificationCommands.map(c => ' - ' + c).join('\n') || ' (none configured)'}
286
+
287
+ ${shoud.rules ? `PROJECT RULES (from SHOUD.md — follow strictly):\n${shoud.rules}\n` : ''}
288
+ GUIDELINES
289
+ - Use structured tools (read_file_range, apply_patch, search_text, git_diff) instead of shelling out for everything.
290
+ - Prefer apply_patch over write_file for small edits.
291
+ - Never modify .env files, secrets, or files inside .shoudignore.
292
+ - Never weaken tests to make them pass. If a test asserts behaviour, fix the code, not the test.
293
+ - When you believe the task is complete, stop calling tools and reply with a short summary.
294
+ The runtime will independently run verification. Do not claim success prematurely.
295
+ - If blocked by an external prerequisite you cannot resolve, say so clearly and stop.
296
+ `;
297
+ }
298
+
299
+ function serializeResult(toolName, result) {
300
+ const { redactDeep } = require('../security/secrets');
301
+ const cleaned = redactDeep(result);
302
+ let s = typeof cleaned === 'string' ? cleaned : JSON.stringify(cleaned, null, 2);
303
+ if (s.length > 16000) s = s.slice(0, 16000) + '\n... [truncated]';
304
+ return s;
305
+ }
306
+
307
+ function estimateCost(data) {
308
+ // Fallback heuristic — backend should really send cost.
309
+ const text = JSON.stringify(data);
310
+ return text.length / 40000; // ~$0.000025 per char, rough
128
311
  }
129
312
 
130
313
  module.exports = { executeTask };
@@ -0,0 +1,30 @@
1
+ const chalk = require('chalk');
2
+
3
+ function createBudgetTracker(limit) {
4
+ return {
5
+ limit,
6
+ spent: 0,
7
+ exceeded() { return this.spent >= this.limit; },
8
+ add(amount) {
9
+ this.spent += amount;
10
+ return this.status();
11
+ },
12
+ status() {
13
+ const remaining = this.limit - this.spent;
14
+ return {
15
+ limit: this.limit, spent: this.spent, remaining,
16
+ warn: remaining <= this.limit * 0.1,
17
+ exceeded: remaining <= 0,
18
+ };
19
+ },
20
+ };
21
+ }
22
+
23
+ function formatBudgetStatus(b) {
24
+ const pct = Math.min(100, (b.spent / b.limit) * 100).toFixed(0);
25
+ const bar = '█'.repeat(Math.round(pct / 5)).padEnd(20, '░');
26
+ const color = b.exceeded ? chalk.red : b.warn ? chalk.yellow : chalk.green;
27
+ return color(` $${b.spent.toFixed(4)} / $${b.limit.toFixed(2)} ${bar}`);
28
+ }
29
+
30
+ module.exports = { createBudgetTracker, formatBudgetStatus };
@@ -0,0 +1,38 @@
1
+ /**
2
+
3
+ Detects when the agent is spinning: same error, same patch, no file changes.
4
+ */
5
+ class NoProgressDetector {
6
+ constructor({ maxRepeats = 3 } = {}) {
7
+ this.maxRepeats = maxRepeats;
8
+ this.errorHistory = new Map();
9
+ this.patchHistory = new Map();
10
+ this.actionsSinceChange = 0;
11
+ }
12
+
13
+ recordAction(action, toolName, input) {
14
+ if (toolName === 'write_file' || toolName === 'apply_patch') {
15
+ const key = JSON.stringify({ path: input.path, content: input.content || input.new_text || '' });
16
+ this.patchHistory.set(key, (this.patchHistory.get(key) || 0) + 1);
17
+ this.actionsSinceChange = 0;
18
+ } else {
19
+ this.actionsSinceChange++;
20
+ }
21
+ }
22
+
23
+ recordResult(toolName, input, result) {
24
+ if (toolName === 'execute_shell' && result && !result.ok) {
25
+ const key = (result.stderr || result.stdout || '').slice(0, 500);
26
+ this.errorHistory.set(key, (this.errorHistory.get(key) || 0) + 1);
27
+ }
28
+ }
29
+
30
+ isStuck() {
31
+ for (const count of this.errorHistory.values()) if (count >= this.maxRepeats) return { reason: 'repeated error' };
32
+ for (const count of this.patchHistory.values()) if (count >= this.maxRepeats) return { reason: 'repeated patch' };
33
+ if (this.actionsSinceChange >= 8) return { reason: 'no file changes after 8 actions' };
34
+ return null;
35
+ }
36
+ }
37
+
38
+ module.exports = { NoProgressDetector };
@@ -0,0 +1,39 @@
1
+ const { runShell } = require('../tools/shell');
2
+ const { redact } = require('../security/secrets');
3
+
4
+ /**
5
+ * Run verification commands. Returns results in the same shape the
6
+ * receipt expects. Streams output to onOutput.
7
+ */
8
+ async function runVerification(projectRoot, commands, { onOutput, timeout = 600000 } = {}) {
9
+ const results = [];
10
+ for (const cmd of commands) {
11
+ if (onOutput) onOutput(`\n● Verifying: ${cmd}\n`);
12
+ const r = await runShell(cmd, {
13
+ cwd: projectRoot,
14
+ timeout,
15
+ onOutput: onOutput ? (chunk) => onOutput(chunk) : undefined,
16
+ });
17
+ const summary = summarizeOutput(r);
18
+ results.push({
19
+ command: cmd,
20
+ ok: r.ok,
21
+ exitCode: r.exitCode,
22
+ duration: r.duration,
23
+ summary: summary.text,
24
+ redactions: summary.redactions,
25
+ });
26
+ }
27
+ return results;
28
+ }
29
+
30
+ function summarizeOutput(result) {
31
+ const combined = [result.stdout, result.stderr].filter(Boolean).join('\n');
32
+ const lines = combined.split('\n');
33
+ // Keep the last 200 lines; redact secrets
34
+ const truncated = lines.slice(-200).join('\n');
35
+ const { text, redactions } = redact(truncated);
36
+ return { text, redactions };
37
+ }
38
+
39
+ module.exports = { runVerification };
@@ -0,0 +1,132 @@
1
+ const fs = require('fs');
2
+ const inquirer = require('inquirer');
3
+ const chalk = require('chalk');
4
+ const { paths } = require('../utils/config');
5
+ const { Risk, RiskLabel, classifyCommand } = require('./riskClassifier');
6
+
7
+ function loadAllowlist() {
8
+ try {
9
+ if (fs.existsSync(paths.ALLOWLIST_FILE)) {
10
+ return JSON.parse(fs.readFileSync(paths.ALLOWLIST_FILE, 'utf-8'));
11
+ }
12
+ } catch (_) {}
13
+ return { commands: [], paths: [], risks: [] };
14
+ }
15
+
16
+ function saveAllowlist(data) {
17
+ fs.writeFileSync(paths.ALLOWLIST_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
18
+ }
19
+
20
+ /**
21
+ * In-memory session allowlist. Cleared when CLI exits.
22
+ */
23
+ function createPermissionSession() {
24
+ return {
25
+ sessionCommands: new Set(),
26
+ sessionPaths: new Set(),
27
+ sessionRisks: new Set(),
28
+ };
29
+ }
30
+
31
+ function isPersistentlyAllowed(session, toolName, input) {
32
+ const allowlist = loadAllowlist();
33
+
34
+ if (toolName === 'read_file' || toolName === 'list_directory' ||
35
+ toolName === 'search_text' || toolName === 'search_files' ||
36
+ toolName === 'read_file_range' || toolName === 'git_status' ||
37
+ toolName === 'git_diff' || toolName === 'git_log' ||
38
+ toolName === 'project_info') {
39
+ return { allowed: true, reason: 'read-only tool' };
40
+ }
41
+
42
+ if (toolName === 'execute_shell') {
43
+ const { risk } = classifyCommand(input.command || '');
44
+ if (risk <= Risk.PROJECT_LOCAL) return { allowed: true, reason: 'low risk' };
45
+
46
+ if (risk >= Risk.LOCAL_MODIFICATION && allowlist.risks.includes(Risk[risk])) {
47
+ return { allowed: true, reason: 'risk-level allowlisted' };
48
+ }
49
+
50
+ const first = (input.command || '').trim().split(/\s+/)[0];
51
+ if (allowlist.commands.includes(first)) return { allowed: true, reason: 'command allowlisted' };
52
+
53
+ if (session.sessionCommands.has(first)) return { allowed: true, reason: 'session allow' };
54
+ if (session.sessionRisks.has(risk)) return { allowed: true, reason: 'session risk allow' };
55
+ }
56
+
57
+ if (toolName === 'write_file' || toolName === 'apply_patch') {
58
+ const p = input.path || '';
59
+ if (allowlist.paths.includes(p) || session.sessionPaths.has(p)) {
60
+ return { allowed: true, reason: 'path allowlisted' };
61
+ }
62
+ }
63
+
64
+ return { allowed: false };
65
+ }
66
+
67
+ async function verifyPermission(session, toolName, input) {
68
+ const pre = isPersistentlyAllowed(session, toolName, input);
69
+ if (pre.allowed) return { allowed: true, reason: pre.reason };
70
+
71
+ // Compute risk for display
72
+ let risk = null;
73
+ let details = '';
74
+ if (toolName === 'execute_shell') {
75
+ const c = classifyCommand(input.command || '');
76
+ risk = c.risk;
77
+ details = c.reasons.join(' · ');
78
+ } else if (toolName === 'write_file' || toolName === 'apply_patch') {
79
+ risk = Risk.LOCAL_MODIFICATION;
80
+ details = `path: ${input.path}`;
81
+ }
82
+
83
+ const riskLabel = risk !== null ? RiskLabel[risk] : 'WRITE';
84
+
85
+ console.log(chalk.yellow(`\n⚠ SHOUD requests permission: ${chalk.bold(toolName)} [${riskLabel}]`));
86
+ if (toolName === 'execute_shell') console.log(chalk.gray(` ${input.command}`));
87
+ else if (input.path) console.log(chalk.gray(` ${input.path}`));
88
+ if (details) console.log(chalk.gray(` (${details})`));
89
+
90
+ const choices = [
91
+ { name: 'Allow once', value: 'once' },
92
+ { name: 'Allow for this session', value: 'session' },
93
+ { name: 'Always allow', value: 'always' },
94
+ { name: 'Deny', value: 'deny' },
95
+ ];
96
+
97
+ // For destructive commands, require type-to-confirm
98
+ if (risk === Risk.DESTRUCTIVE) {
99
+ console.log(chalk.red.bold('\n ⚠ DESTRUCTIVE OPERATION — requires explicit confirmation.'));
100
+ const { confirm } = await inquirer.prompt([{
101
+ type: 'input', name: 'confirm',
102
+ message: 'Type "confirm" to proceed:',
103
+ }]);
104
+ if (confirm !== 'confirm') return { allowed: false, reason: 'user refused destructive op' };
105
+ // Still confirm permission below
106
+ }
107
+
108
+ const { permission } = await inquirer.prompt([{
109
+ type: 'list', name: 'permission',
110
+ message: 'Allow this operation?',
111
+ choices,
112
+ }]);
113
+
114
+ if (permission === 'deny') return { allowed: false, reason: 'user denied' };
115
+
116
+ if (permission === 'session' || permission === 'always') {
117
+ const allowlist = loadAllowlist();
118
+ if (toolName === 'execute_shell') {
119
+ const first = (input.command || '').trim().split(/\s+/)[0];
120
+ if (permission === 'session') session.sessionCommands.add(first);
121
+ else if (!allowlist.commands.includes(first)) { allowlist.commands.push(first); saveAllowlist(allowlist); }
122
+ } else if (toolName === 'write_file' || toolName === 'apply_patch') {
123
+ const p = input.path;
124
+ if (permission === 'session') session.sessionPaths.add(p);
125
+ else if (!allowlist.paths.includes(p)) { allowlist.paths.push(p); saveAllowlist(allowlist); }
126
+ }
127
+ }
128
+
129
+ return { allowed: true, reason: permission };
130
+ }
131
+
132
+ module.exports = { verifyPermission, createPermissionSession, loadAllowlist };