nexrall-code 0.5.116 → 0.5.118
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/dist/index.js +1779 -1078
- package/package.json +1 -6
- package/dist/audit.js +0 -102
- package/dist/commands/auth.js +0 -134
- package/dist/commands/chat.js +0 -2715
- package/dist/commands/plugin.js +0 -393
- package/dist/commands/sessions.js +0 -62
- package/dist/commands/update.js +0 -174
- package/dist/permissions/handler.js +0 -332
- package/dist/terminal/terminalSetup.js +0 -395
- package/dist/trust.js +0 -137
- package/dist/ui/diff.js +0 -51
- package/dist/ui/inkReadlineAdapter.js +0 -116
- package/dist/ui/inkTerminal.js +0 -1097
- package/dist/ui/resizeRepaint.js +0 -36
- package/dist/ui/screen.js +0 -482
- package/dist/ui/theme.js +0 -385
- package/dist/ui/trustPrompt.js +0 -126
|
@@ -1,332 +0,0 @@
|
|
|
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
|
-
}
|