nexrall-code 0.5.107 → 0.5.109

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.
@@ -0,0 +1,332 @@
1
+ import * as readline from 'readline';
2
+ import * as fs from 'fs';
3
+ import chalk from 'chalk';
4
+ import { showDiff } from '../ui/diff';
5
+ import { loadSettings, evaluatePermission, isDestructiveBash, decide as decidePolicy, parseMode, } from '@nexrall/code-core';
6
+ // ─── Session State ────────────────────────────────────────────────────────────
7
+ // Categories: 'read' | 'write' | 'bash' | 'fs' | 'all'
8
+ const autoApproved = new Set();
9
+ // Granular allow/ask/deny rules merged from the settings tiers.
10
+ let _rules = { allow: [], ask: [], deny: [] };
11
+ let _workDir = process.cwd();
12
+ // Current agent mode ('ask' | 'edit' | 'plan' | 'auto'). In 'plan' mode every
13
+ // mutating tool call is REFUSED at the permission gate — not merely discouraged
14
+ // by the system prompt — so a plan-mode session can never write a file even if
15
+ // the model ignores its instructions (prompt injection, misread context, etc.).
16
+ // Mirrors the VS Code extension's hard gate in ChatPanel.ts.
17
+ let _mode = 'ask';
18
+ /** Load merged permission rules from the settings hierarchy for this workspace. */
19
+ export function initPermissions(workDir) {
20
+ _workDir = workDir;
21
+ _rules = loadSettings(workDir).permissions;
22
+ return _rules;
23
+ }
24
+ /** Set the active agent mode so the plan-mode gate can enforce read-only. */
25
+ export function setMode(mode) {
26
+ // parseMode normalises unknown values to the SAFE middle ('ask') rather than the
27
+ // permissive default this used to fall back to. A typo in a settings file or a stale
28
+ // client sending a mode this build doesn't know must not silently grant autonomy.
29
+ _mode = parseMode(mode);
30
+ }
31
+ export function setAutoApprove(category) {
32
+ autoApproved.add(category);
33
+ }
34
+ export function resetPermissions() {
35
+ autoApproved.clear();
36
+ }
37
+ function isAutoApproved(category) {
38
+ return autoApproved.has('all') || autoApproved.has(category);
39
+ }
40
+ /** Public read of the 'all' auto-approve flag, for the status-bar footer. */
41
+ export function isYoloMode() {
42
+ return autoApproved.has('all');
43
+ }
44
+ // ─── Readline Prompt ──────────────────────────────────────────────────────────
45
+ // The single, long-lived readline.Interface owned by the REPL (chat.ts). Every
46
+ // permission prompt reuses THIS interface instead of creating a new one.
47
+ //
48
+ // Why this matters: readline.Interface#pause() only pauses the underlying
49
+ // stream — it does NOT remove the 'keypress'/'data' listeners the interface
50
+ // registered on process.stdin. chat.ts calls rl.pause() before each agent turn
51
+ // (so its own prompt doesn't compete with the run), then requestPermission()
52
+ // used to spin up a BRAND NEW readline.Interface on the same process.stdin to
53
+ // ask "Run this command? [y/n]". That left two Interfaces alive and listening
54
+ // on the same stdin at once, so every keystroke (including the y/n answer)
55
+ // got processed — and the line re-drawn — by both, producing the classic
56
+ // symptom users reported: typed characters appearing doubled/duplicated and
57
+ // the prompt failing to advance to a clean new line. Routing every question
58
+ // through the one REPL interface (via setReadlineInterface) eliminates the
59
+ // second listener entirely.
60
+ let _replInterface = null;
61
+ /** Called once by chat.ts right after it creates the REPL's readline.Interface (or Ink adapter). */
62
+ export function setReadlineInterface(rl) {
63
+ _replInterface = rl;
64
+ }
65
+ // ─── One prompt at a time ─────────────────────────────────────────────────────
66
+ //
67
+ // Up to 4 sub-agents run concurrently, each issuing its own tool calls, so two of them
68
+ // needing approval at the same moment both reach this function. That used to mean two
69
+ // concurrent `rl.question()` calls on ONE readline interface, which does not merely look
70
+ // untidy — the second callback never fires. Verified directly: with two questions
71
+ // outstanding and two lines piped in, only the first is ever answered and the second
72
+ // promise hangs forever. A wedged permission request wedges the whole turn, and the
73
+ // user's instinct (Ctrl+C, retry) is the wrong move because the work was fine.
74
+ //
75
+ // So prompts are serialised: at most one question is on screen, the rest wait their
76
+ // turn. Every OTHER path here (deny rules, session auto-approve, allow rules) is
77
+ // synchronous and unaffected — only the branch that actually asks a human is queued.
78
+ let _promptChain = Promise.resolve();
79
+ function serialisePrompt(fn) {
80
+ // Chain onto the previous prompt, swallowing its rejection so one failed prompt
81
+ // cannot poison every later one.
82
+ const next = _promptChain.then(fn, fn);
83
+ _promptChain = next.catch(() => undefined);
84
+ return next;
85
+ }
86
+ /**
87
+ * Ask the user a question. Reuses the shared REPL interface when available
88
+ * (the normal interactive-session case) so only one readline.Interface is ever
89
+ * listening on stdin. Falls back to a throwaway interface for any caller that
90
+ * hasn't registered one (e.g. standalone/test usage outside the REPL).
91
+ * Returns the trimmed response.
92
+ *
93
+ * The question text is always written to stderr first (so it doesn't mix with
94
+ * anything piped from stdout), then rl.question() is called with an empty
95
+ * query — the shared REPL interface's output is stdout, but by the time
96
+ * question() draws anything the prompt text is already on stderr, matching
97
+ * the original stdout/stderr separation.
98
+ */
99
+ function askUser(question) {
100
+ // Serialised HERE rather than at each of the eight call sites: this is the single
101
+ // choke point where a human is actually asked, so wrapping it means a new prompt added
102
+ // later is safe by default instead of depending on whoever adds it remembering.
103
+ return serialisePrompt(() => {
104
+ if (_replInterface) {
105
+ const rl = _replInterface;
106
+ return new Promise((resolve) => {
107
+ rl.question(question, (answer) => resolve(answer.trim()));
108
+ });
109
+ }
110
+ return new Promise((resolve) => {
111
+ const rl = readline.createInterface({
112
+ input: process.stdin,
113
+ output: process.stderr,
114
+ terminal: true,
115
+ });
116
+ rl.question(question, (answer) => {
117
+ rl.close();
118
+ resolve(answer.trim());
119
+ });
120
+ });
121
+ });
122
+ }
123
+ // ─── Permission Request ───────────────────────────────────────────────────────
124
+ export async function requestPermission(req) {
125
+ const { tool, input } = req;
126
+ // ── Granular rules (deny → allow → ask) from settings tiers ───────────────
127
+ const decision = evaluatePermission(_rules, tool, input, _workDir);
128
+ if (decision === 'deny') {
129
+ console.error(chalk.red(` ✖ Denied by permission rule: ${tool}`));
130
+ return false;
131
+ }
132
+ // ── Destructive-command guard (DB drops, force-push, terraform destroy …) ──
133
+ // Fires AFTER an explicit deny but BEFORE any allow / session auto-approve, so
134
+ // an irreversible high-blast-radius command can never ride a blanket "approve
135
+ // all" straight to execution (the class of failure that has wiped prod DBs).
136
+ // Opt-out for CI/headless via NEXRALL_ALLOW_DESTRUCTIVE=1.
137
+ const destructive = isDestructiveBash(tool, input);
138
+ if (destructive && process.env.NEXRALL_ALLOW_DESTRUCTIVE !== '1') {
139
+ const command = (input.command ?? input.cmd ?? '');
140
+ console.error();
141
+ console.error(chalk.red.bold(' ⚠ DESTRUCTIVE / IRREVERSIBLE COMMAND'));
142
+ console.error(chalk.red(` ${destructive.reason} [${destructive.category}]`));
143
+ console.error(chalk.yellow(' ┌─ bash ──────────────────────────────────────────'));
144
+ for (const line of command.split('\n')) {
145
+ console.error(chalk.yellow(' │ ') + chalk.white(line));
146
+ }
147
+ console.error(chalk.yellow(' └────────────────────────────────────────────────'));
148
+ console.error(chalk.dim(' This cannot be undone. Auto-approve does not apply here.'));
149
+ console.error();
150
+ // Require an explicit, unambiguous "yes" — no [a(ll)], no default-y.
151
+ const answer = await askUser(chalk.bold.red('Type "yes" to run this destructive command: '));
152
+ return answer.toLowerCase() === 'yes';
153
+ }
154
+ if (decision === 'allow')
155
+ return true;
156
+ // 'ask' or no matching rule → fall through to the interactive flow below.
157
+ // ── Mode policy ───────────────────────────────────────────────────────────
158
+ //
159
+ // Delegated to the shared policy in core rather than decided here. This file used to
160
+ // read `_mode` in exactly ONE place (`=== 'plan'`), so `ask`, `edit` and `auto` were
161
+ // indistinguishable: choosing `auto` still prompted for `npm test`, and `edit` did
162
+ // nothing whatsoever. Meanwhile the system prompt already told the model that in
163
+ // `auto` "file edits proceed" and only irreversible actions need confirmation — an
164
+ // accurate statement of intent and a false description of the product.
165
+ //
166
+ // The policy owns the read-only list too, so the CLI and the VS Code panel stop
167
+ // maintaining two copies that had already drifted apart.
168
+ const verdict = decidePolicy({
169
+ tool,
170
+ input,
171
+ mode: _mode,
172
+ bypass: isAutoApproved('all'),
173
+ // The destructive case was already handled above with the strong confirm flow
174
+ // (type "yes", no "approve all"), so it must not be re-triggered here.
175
+ destructive: false,
176
+ });
177
+ if (verdict === 'allow')
178
+ return true;
179
+ if (verdict === 'deny') {
180
+ console.error(chalk.yellow(` ⊘ Plan mode — refused ${tool} (read-only until you switch mode).`));
181
+ return false;
182
+ }
183
+ // 'ask' → fall through to the per-tool flows below, which render a diff or command
184
+ // preview before asking.
185
+ // ── Write file (full overwrite) ───────────────────────────────────────────
186
+ if (tool === 'write_file' || tool === 'create_file') {
187
+ if (isAutoApproved('write'))
188
+ return true;
189
+ const filePath = (input.path ?? input.file_path ?? '');
190
+ const newContent = (input.content ?? '');
191
+ let oldContent = null;
192
+ try {
193
+ if (fs.existsSync(filePath))
194
+ oldContent = fs.readFileSync(filePath, 'utf-8');
195
+ }
196
+ catch { /* new file */ }
197
+ showDiff(filePath, oldContent, newContent);
198
+ const answer = await askUser(chalk.bold('Apply this change?') + chalk.dim(' [y/n/a(ll)] > '));
199
+ if (answer === 'a' || answer === 'all') {
200
+ setAutoApprove('write');
201
+ return true;
202
+ }
203
+ return answer === 'y' || answer === 'yes';
204
+ }
205
+ // ── Edit file (targeted patch) ────────────────────────────────────────────
206
+ if (tool === 'edit_file') {
207
+ if (isAutoApproved('write'))
208
+ return true;
209
+ const filePath = (input.path ?? '');
210
+ const oldStr = (input.old_string ?? '');
211
+ const newStr = (input.new_string ?? '');
212
+ let oldContent = null;
213
+ let newContent = '';
214
+ try {
215
+ if (fs.existsSync(filePath)) {
216
+ oldContent = fs.readFileSync(filePath, 'utf-8');
217
+ newContent = oldContent.replace(oldStr, newStr);
218
+ }
219
+ }
220
+ catch { /* ignore */ }
221
+ if (oldContent !== null) {
222
+ showDiff(filePath, oldContent, newContent);
223
+ }
224
+ else {
225
+ console.error(chalk.yellow(`\n edit_file: ${filePath}`));
226
+ console.error(chalk.red(` - ${oldStr.split('\n')[0]}…`));
227
+ console.error(chalk.green(` + ${newStr.split('\n')[0]}…`));
228
+ console.error();
229
+ }
230
+ const answer = await askUser(chalk.bold('Apply this edit?') + chalk.dim(' [y/n/a(ll)] > '));
231
+ if (answer === 'a' || answer === 'all') {
232
+ setAutoApprove('write');
233
+ return true;
234
+ }
235
+ return answer === 'y' || answer === 'yes';
236
+ }
237
+ // ── Multi-edit (multiple patches in one file) ─────────────────────────────
238
+ if (tool === 'multi_edit') {
239
+ if (isAutoApproved('write'))
240
+ return true;
241
+ const filePath = (input.path ?? '');
242
+ const edits = Array.isArray(input.edits)
243
+ ? input.edits
244
+ : [];
245
+ let oldContent = null;
246
+ let newContent = '';
247
+ try {
248
+ if (fs.existsSync(filePath)) {
249
+ oldContent = fs.readFileSync(filePath, 'utf-8');
250
+ newContent = edits.reduce((src, e) => src.replace(e.old_string, e.new_string), oldContent);
251
+ }
252
+ }
253
+ catch { /* ignore */ }
254
+ if (oldContent !== null) {
255
+ showDiff(filePath, oldContent, newContent);
256
+ }
257
+ else {
258
+ console.error(chalk.yellow(`\n multi_edit: ${filePath} (${edits.length} change${edits.length !== 1 ? 's' : ''})\n`));
259
+ }
260
+ const answer = await askUser(chalk.bold(`Apply ${edits.length} edit${edits.length !== 1 ? 's' : ''}?`) + chalk.dim(' [y/n/a(ll)] > '));
261
+ if (answer === 'a' || answer === 'all') {
262
+ setAutoApprove('write');
263
+ return true;
264
+ }
265
+ return answer === 'y' || answer === 'yes';
266
+ }
267
+ // ── Bash / run_command ────────────────────────────────────────────────────
268
+ if (tool === 'bash' || tool === 'run_command' || tool === 'execute_command') {
269
+ if (isAutoApproved('bash'))
270
+ return true;
271
+ const command = (input.command ?? input.cmd ?? '');
272
+ console.error();
273
+ console.error(chalk.yellow(' ┌─ bash ──────────────────────────────────────────'));
274
+ for (const line of command.split('\n')) {
275
+ console.error(chalk.yellow(' │ ') + chalk.white(line));
276
+ }
277
+ console.error(chalk.yellow(' └────────────────────────────────────────────────'));
278
+ console.error();
279
+ const answer = await askUser(chalk.bold('Run this command?') + chalk.dim(' [y/n/a(ll)] > '));
280
+ if (answer === 'a' || answer === 'all') {
281
+ setAutoApprove('bash');
282
+ return true;
283
+ }
284
+ return answer === 'y' || answer === 'yes';
285
+ }
286
+ // ── Notebook edit ─────────────────────────────────────────────────────────
287
+ if (tool === 'notebook_edit') {
288
+ if (isAutoApproved('write'))
289
+ return true;
290
+ const filePath = (input.path ?? '');
291
+ const editType = (input.edit_type ?? 'edit');
292
+ const cellIdx = input.cell_index != null ? `cell ${input.cell_index}` : '';
293
+ console.error(chalk.yellow(`\n notebook_edit: ${filePath} [${editType} ${cellIdx}]\n`));
294
+ const answer = await askUser(chalk.bold('Apply this notebook edit?') + chalk.dim(' [y/n/a(ll)] > '));
295
+ if (answer === 'a' || answer === 'all') {
296
+ setAutoApprove('write');
297
+ return true;
298
+ }
299
+ return answer === 'y' || answer === 'yes';
300
+ }
301
+ // ── Copy file ─────────────────────────────────────────────────────────────
302
+ if (tool === 'copy_file') {
303
+ if (isAutoApproved('fs'))
304
+ return true;
305
+ const src = (input.source ?? '');
306
+ const dest = (input.destination ?? '');
307
+ console.error(chalk.yellow(`\n copy_file `) + chalk.white(`${src} → ${dest}\n`));
308
+ const answer = await askUser(chalk.bold('Allow copy?') + chalk.dim(' [y/n] > '));
309
+ return answer === 'y' || answer === 'yes';
310
+ }
311
+ // ── Filesystem mutations (create_directory, move_file, delete_file) ───────
312
+ if (['create_directory', 'move_file', 'delete_file', 'rename_file'].includes(tool)) {
313
+ if (isAutoApproved('fs'))
314
+ return true;
315
+ const src = (input.path ?? input.source ?? input.src ?? '');
316
+ const dest = (input.destination ?? input.dest ?? '');
317
+ const action = tool.replace(/_/g, ' ');
318
+ const detail = dest ? `${src} → ${dest}` : src;
319
+ console.error();
320
+ console.error(chalk.yellow(' ') + chalk.bold(action) + chalk.dim(' ') + chalk.white(detail));
321
+ console.error();
322
+ const answer = await askUser(chalk.bold(`Allow ${action}?`) + chalk.dim(' [y/n] > '));
323
+ return answer === 'y' || answer === 'yes';
324
+ }
325
+ // ── Unknown tool: prompt ──────────────────────────────────────────────────
326
+ console.error();
327
+ console.error(chalk.dim(` Unknown tool: ${chalk.bold(tool)}`));
328
+ console.error(chalk.dim(` Input: ${JSON.stringify(input).slice(0, 120)}`));
329
+ console.error();
330
+ const answer = await askUser(chalk.bold(`Allow ${tool}?`) + chalk.dim(' [y/n] > '));
331
+ return answer === 'y' || answer === 'yes';
332
+ }