flashlite 0.0.0-stage → 0.1.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.
@@ -0,0 +1,544 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ // A best-effort recognizer, NOT a shell interpreter or a safety proof. Unlike
5
+ // guarded auto, unfamiliar/opaque syntax is allowed. Only recognizable massive,
6
+ // irreversible operations need an extra one-command confirmation in absolute.
7
+ function riskSegments(command) {
8
+ const segments = [[]];
9
+ let text = '', expansion = false, started = false;
10
+ let quote = '';
11
+ const word = () => {
12
+ if (started)
13
+ segments.at(-1).push({ text, expansion });
14
+ text = '';
15
+ expansion = false;
16
+ started = false;
17
+ };
18
+ for (let i = 0; i < command.length; i++) {
19
+ const char = command[i];
20
+ if (char === '\\' && quote !== "'") {
21
+ started = true;
22
+ if (i + 1 < command.length)
23
+ text += command[++i];
24
+ }
25
+ else if (quote) {
26
+ if (char === quote)
27
+ quote = '';
28
+ else {
29
+ text += char;
30
+ if (quote === '"' && char === '$')
31
+ expansion = true;
32
+ }
33
+ }
34
+ else if (char === '"' || char === "'") {
35
+ quote = char;
36
+ started = true;
37
+ }
38
+ else if (char === '>') {
39
+ word();
40
+ const operator = command[i + 1] === '>' ? '>>' : '>';
41
+ if (operator === '>>')
42
+ i++;
43
+ segments.at(-1).push({ text: operator, expansion: false, redirect: true });
44
+ }
45
+ else if (';|&\n()'.includes(char)) {
46
+ word();
47
+ if (segments.at(-1).length)
48
+ segments.push([]);
49
+ }
50
+ else if (/\s/.test(char))
51
+ word();
52
+ else {
53
+ started = true;
54
+ text += char;
55
+ if ('$~*?['.includes(char))
56
+ expansion = true;
57
+ }
58
+ }
59
+ word();
60
+ return segments;
61
+ }
62
+ const HUGE_DELETE_RISK = 'Mass irreversible deletion of a filesystem root, home, or system/volume tree.';
63
+ const HUGE_DISK_RISK = 'Disk/volume erasure, formatting, or raw disk overwrite can irreversibly destroy widespread data.';
64
+ const rawDisk = (value) => /^\/dev\/(?:r?disk\d+(?:s\d+)*|sd[a-z]\d*|nvme\d+n\d+(?:p\d+)?|mmcblk\d+(?:p\d+)?)$/.test(value);
65
+ function broadDeleteTarget(word, cwd) {
66
+ let target = word.text;
67
+ if (word.expansion) {
68
+ target = target.replace(/^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/, os.homedir());
69
+ // Whole-tree globs, not a selective filename pattern or one home subdirectory.
70
+ if (['*', '.*', '**'].includes(target))
71
+ target = '.';
72
+ else
73
+ target = target.replace(/\/(?:\*|\.\*|\*\*)(?:\/\*)?$/, '/') || '/';
74
+ }
75
+ if (/[$*?[\]{}]/.test(target))
76
+ return false;
77
+ const resolved = path.resolve(cwd, target);
78
+ return new Set(['/', os.homedir(), '/Users', '/System', '/Library', '/Volumes', '/Applications', '/usr', '/bin', '/sbin', '/etc', '/private', '/private/etc', '/private/var', '/var']).has(resolved) ||
79
+ /^\/(?:Users|Volumes)\/[^/]+$/.test(resolved);
80
+ }
81
+ /** Narrow exceptional confirmation in absolute mode. No allowlist and no
82
+ * rejection just because a command is destructive, elevated, unknown or opaque.
83
+ * Does not inspect script files, aliases, remote code or arbitrary expansions.
84
+ */
85
+ export function absoluteCommandRisk(command, cwd, depth = 0) {
86
+ if (depth > 4)
87
+ return;
88
+ for (const segment of riskSegments(command)) {
89
+ let words = segment;
90
+ while (words.length && ['if', 'elif', 'then', 'else', 'while', 'until', 'do', '!'].includes(words[0].text))
91
+ words = words.slice(1);
92
+ // Common literal wrappers do not make a recognizable catastrophe invisible.
93
+ while (words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0].text) ||
94
+ ['sudo', 'command', 'exec', 'env', 'nohup'].includes(path.basename(words[0].text)))) {
95
+ words = words.slice(1);
96
+ while (words[0]?.text.startsWith('-'))
97
+ words = words.slice(1);
98
+ }
99
+ if (!words.length)
100
+ continue;
101
+ const executable = path.basename(words[0].text);
102
+ const args = words.slice(1);
103
+ if (['sh', 'zsh', 'bash'].includes(executable)) {
104
+ const index = args.findIndex(word => /^-[a-z]*c[a-z]*$/.test(word.text));
105
+ if (index >= 0 && args[index + 1]) {
106
+ const risk = absoluteCommandRisk(args[index + 1].text, cwd, depth + 1);
107
+ if (risk)
108
+ return risk;
109
+ }
110
+ }
111
+ if (executable === 'rm') {
112
+ let recursive = false, options = true;
113
+ const targets = [];
114
+ for (const arg of args) {
115
+ if (options && arg.text === '--') {
116
+ options = false;
117
+ continue;
118
+ }
119
+ if (options && arg.text.startsWith('-')) {
120
+ recursive ||= arg.text === '--recursive' || /^-[^-]*[rR]/.test(arg.text);
121
+ }
122
+ else
123
+ targets.push(arg);
124
+ }
125
+ if (recursive && targets.some(word => broadDeleteTarget(word, cwd)))
126
+ return HUGE_DELETE_RISK;
127
+ }
128
+ if (executable === 'diskutil' && (/^(?:eraseDisk|eraseVolume|partitionDisk|secureErase|zeroDisk|randomDisk)$/i.test(args[0]?.text ?? '') ||
129
+ (args[0]?.text.toLowerCase() === 'apfs' && /^(?:deleteContainer|deleteVolume)$/i.test(args[1]?.text ?? ''))))
130
+ return HUGE_DISK_RISK;
131
+ if (/^(?:mkfs(?:\..+)?|newfs(?:_.+)?)$/.test(executable) && args.some(word => rawDisk(word.text)))
132
+ return HUGE_DISK_RISK;
133
+ if (executable === 'dd' && args.some(word => word.text.startsWith('of=') && rawDisk(word.text.slice(3))))
134
+ return HUGE_DISK_RISK;
135
+ // Raw-device shell redirection, including `> /dev/diskN` and `>/dev/diskN`.
136
+ if (segment.some((word, i) => word.redirect && rawDisk(segment[i + 1]?.text ?? '')))
137
+ return HUGE_DISK_RISK;
138
+ }
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Guarded auto classifier.
142
+ //
143
+ // Guarded auto is trust-by-default: it runs arbitrary terminal commands without a
144
+ // per-call prompt. Exactly five categories still ask (or are refused when obviously
145
+ // catastrophic), and no amount of wrapping, pipelines, or env assignments can hide
146
+ // them: network access, installs/package managers, privilege escalation, paths
147
+ // outside the project, and destructive Git / disk operations. Syntax this parser
148
+ // cannot confidently reduce to literal words (substitutions, here-docs, globs,
149
+ // eval/source, subshells, background) fails closed and asks.
150
+ // ---------------------------------------------------------------------------
151
+ const ALLOW = { auto: true, reason: 'Allowed by guarded auto; commands are not sandboxed and this is not a safety guarantee.' };
152
+ const NETWORK_TOOLS = new Set([
153
+ 'curl', 'wget', 'nc', 'netcat', 'ncat', 'telnet', 'ftp', 'lftp', 'sftp', 'ssh', 'scp',
154
+ 'rsync', 'rclone', 'socat', 'dig', 'nslookup', 'host', 'ping', 'ping6', 'traceroute',
155
+ 'mtr', 'whois', 'ssh-copy-id', 'openssl',
156
+ ]);
157
+ // `git` subcommands that reach the network (or rewrite remote history).
158
+ const GIT_NETWORK = new Set(['fetch', 'pull', 'push', 'clone', 'remote', 'submodule', 'ls-remote', 'send-pack', 'receive-pack']);
159
+ // `git` subcommands that can destroy local work; the specific dangerous flags decide.
160
+ const GIT_DESTRUCTIVE = new Set(['reset', 'clean', 'branch']);
161
+ const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env']);
162
+ // Package managers that install/upgrade by their very invocation.
163
+ const PACKAGE_MANAGERS = new Set([
164
+ 'brew', 'port', 'pip', 'pip3', 'pipx', 'apt', 'apt-get', 'apt-cache', 'dpkg', 'yum',
165
+ 'dnf', 'zypper', 'pacman', 'snap', 'flatpak', 'nix', 'nix-env', 'mas', 'composer',
166
+ 'conda', 'mamba', 'rustup', 'npx',
167
+ ]);
168
+ // Runners whose subcommand decides whether they install or merely build/test.
169
+ const NPM_LIKE = new Set(['npm', 'pnpm', 'yarn', 'bun']);
170
+ const NPM_INSTALL = new Set(['install', 'i', 'ci', 'add', 'remove', 'rm', 'uninstall', 'update', 'upgrade', 'link', 'publish', 'exec', 'dlx', 'create', 'init', 'pack', 'audit', 'fund', 'outdated', 'dedupe', 'prune', 'rebuild']);
171
+ const SUBCOMMAND_INSTALL = {
172
+ cargo: new Set(['install', 'add', 'update', 'publish', 'yank', 'login', 'logout', 'owner']),
173
+ go: new Set(['get', 'install']),
174
+ gem: new Set(['install', 'uninstall', 'update', 'pristine', 'build']),
175
+ poetry: new Set(['add', 'install', 'update', 'lock', 'remove', 'sync']),
176
+ pipenv: new Set(['install', 'update', 'uninstall', 'sync', 'lock']),
177
+ bundle: new Set(['install', 'add', 'update', 'remove', 'lock']),
178
+ };
179
+ const SUDO_TOOLS = new Set(['sudo', 'doas', 'su', 'sudoedit', 'pkexec', 'run0']);
180
+ const OPAQUE_COMMANDS = new Set(['eval', 'source', '.', 'xargs']);
181
+ const SHELLS = new Set(['sh', 'zsh', 'bash', 'ash', 'dash', 'ksh']);
182
+ // Literal wrappers whose prefix is stripped so a category cannot hide behind them.
183
+ const WRAPPERS = new Set(['env', 'command', 'exec', 'nohup', 'nice', 'ionice', 'setsid', 'time', 'timeout', 'stdbuf', 'caffeinate']);
184
+ const WRAPPER_VALUE_FLAGS = {
185
+ nice: new Set(['-n']),
186
+ ionice: new Set(['-c', '-n', '-p']),
187
+ stdbuf: new Set(['-i', '-o', '-e']),
188
+ timeout: new Set(['-s', '-k']),
189
+ };
190
+ /** Split a command into segments of literal-ish words. Returns an `opaque` reason
191
+ * for shell syntax that cannot be reduced to inspectable words (it must ask). */
192
+ function parseWords(command) {
193
+ const segments = [[]];
194
+ let text = '', dynamic = false, started = false, quote = '';
195
+ const word = () => {
196
+ if (started)
197
+ segments.at(-1).push({ text, dynamic, redirect: false });
198
+ text = '';
199
+ dynamic = false;
200
+ started = false;
201
+ };
202
+ const split = () => {
203
+ word();
204
+ if (!segments.at(-1).length)
205
+ return false;
206
+ segments.push([]);
207
+ return true;
208
+ };
209
+ for (let i = 0; i < command.length; i++) {
210
+ const char = command[i];
211
+ if (quote === "'") {
212
+ started = true;
213
+ if (char === "'")
214
+ quote = '';
215
+ else
216
+ text += char;
217
+ continue;
218
+ }
219
+ if (quote === '"') {
220
+ started = true;
221
+ if (char === '"') {
222
+ quote = '';
223
+ continue;
224
+ }
225
+ if (char === '\\') {
226
+ const next = command[++i];
227
+ if (next === undefined)
228
+ return { segments, opaque: 'a trailing escape' };
229
+ text += '"\\$`'.includes(next) ? next : '\\' + next;
230
+ continue;
231
+ }
232
+ if (char === '$' || char === '`') {
233
+ dynamic = true;
234
+ text += char;
235
+ continue;
236
+ }
237
+ text += char;
238
+ continue;
239
+ }
240
+ if (char === '\\') {
241
+ started = true;
242
+ const next = command[++i];
243
+ if (next === undefined)
244
+ return { segments, opaque: 'a trailing escape' };
245
+ text += next;
246
+ continue;
247
+ }
248
+ if (char === "'" || char === '"') {
249
+ quote = char;
250
+ started = true;
251
+ continue;
252
+ }
253
+ if (char === '`')
254
+ return { segments, opaque: 'command substitution' };
255
+ if (char === '$' && command[i + 1] === '(')
256
+ return { segments, opaque: 'command substitution' };
257
+ if (char === '$') {
258
+ started = true;
259
+ dynamic = true;
260
+ text += char;
261
+ continue;
262
+ }
263
+ if (char === '<' && command[i + 1] === '<')
264
+ return { segments, opaque: 'a here-document' };
265
+ if (char === '(' || char === ')' || char === '{' || char === '}')
266
+ return { segments, opaque: 'a subshell or group' };
267
+ if (char === ';' || char === '\n' || char === '|') {
268
+ if (!split() && segments.length === 1)
269
+ return { segments, opaque: 'a leading operator' };
270
+ continue;
271
+ }
272
+ if (char === '&') {
273
+ if (command[i + 1] === '&') {
274
+ if (!split() && segments.length === 1)
275
+ return { segments, opaque: 'a leading operator' };
276
+ i++;
277
+ continue;
278
+ }
279
+ return { segments, opaque: 'background or file-descriptor syntax' };
280
+ }
281
+ if (char === '>') {
282
+ word();
283
+ let op = '>';
284
+ while (command[i + 1] === '>') {
285
+ op += '>';
286
+ i++;
287
+ }
288
+ if (op.length > 2)
289
+ return { segments, opaque: 'file-descriptor syntax' };
290
+ segments.at(-1).push({ text: op, dynamic: false, redirect: true });
291
+ continue;
292
+ }
293
+ if (char === '<') {
294
+ word();
295
+ segments.at(-1).push({ text: '<', dynamic: false, redirect: true });
296
+ continue;
297
+ }
298
+ if (char === '#' && !started)
299
+ break; // comment through end of the segment
300
+ if (/\s/.test(char)) {
301
+ word();
302
+ continue;
303
+ }
304
+ started = true;
305
+ text += char;
306
+ if ('*?['.includes(char))
307
+ dynamic = true;
308
+ }
309
+ word();
310
+ if (segments.length > 1 && segments.at(-1).length === 0) {
311
+ return { segments: segments.slice(0, -1), opaque: 'a trailing operator' };
312
+ }
313
+ return { segments: segments.filter((segment) => segment.length) };
314
+ }
315
+ /** Strip env assignments and literal wrappers, leaving the command actually run. */
316
+ function effectiveWords(words) {
317
+ let i = 0;
318
+ for (;;) {
319
+ const current = words[i];
320
+ if (!current)
321
+ return [];
322
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(current.text)) {
323
+ i++;
324
+ continue;
325
+ }
326
+ const base = path.basename(current.text);
327
+ if (!WRAPPERS.has(base))
328
+ return words.slice(i);
329
+ i++;
330
+ if (base === 'timeout') {
331
+ while (words[i]?.text.startsWith('-'))
332
+ i++;
333
+ if (words[i] && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i].text))
334
+ i++; // the duration
335
+ continue;
336
+ }
337
+ const valueFlags = WRAPPER_VALUE_FLAGS[base];
338
+ while (words[i]?.text.startsWith('-'))
339
+ i += valueFlags?.has(words[i].text) ? 2 : 1;
340
+ }
341
+ }
342
+ /** First non-flag operand, skipping the value of known value-taking flags. */
343
+ function firstOperand(args, valueFlags = new Set()) {
344
+ for (let i = 0; i < args.length; i++) {
345
+ const text = args[i].text;
346
+ if (text.startsWith('-')) {
347
+ if (valueFlags.has(text))
348
+ i++;
349
+ continue;
350
+ }
351
+ return text;
352
+ }
353
+ }
354
+ const CONFIG_DIR_UNDER_HOME = path.join('.config', 'flashlite');
355
+ /** Read-only exception: the CLI's own `~/.config/flashlite` tree. */
356
+ function configSubtree(value) {
357
+ if (!value.startsWith('~/'))
358
+ return false;
359
+ const expected = path.join(os.homedir(), CONFIG_DIR_UNDER_HOME);
360
+ const target = path.resolve(os.homedir(), value.slice(2));
361
+ const relative = path.relative(expected, target);
362
+ return relative === '' || (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative));
363
+ }
364
+ /** A relative path is fine only while its deepest existing ancestor stays inside. */
365
+ async function resolvesInsideProject(value, root) {
366
+ const resolved = path.resolve(root, value);
367
+ const relative = path.relative(root, resolved);
368
+ if (relative === '' || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative))
369
+ return false;
370
+ let ancestor = resolved;
371
+ for (;;) {
372
+ try {
373
+ const real = await fs.realpath(ancestor);
374
+ const realRelative = path.relative(root, real);
375
+ return realRelative === '' || (realRelative !== '..' && !realRelative.startsWith('..' + path.sep) && !path.isAbsolute(realRelative));
376
+ }
377
+ catch {
378
+ const parent = path.dirname(ancestor);
379
+ if (parent === ancestor)
380
+ return false;
381
+ ancestor = parent;
382
+ }
383
+ }
384
+ }
385
+ function hasDotDot(value) {
386
+ return value.split('/').includes('..');
387
+ }
388
+ /** Check one path-like token. Returns a fixed reason when it leaves the project. */
389
+ async function outsideReason(value, root, redirectTarget) {
390
+ if (!value)
391
+ return undefined;
392
+ if (value.startsWith('/')) {
393
+ if (redirectTarget && ['/dev/null', '/dev/stdout', '/dev/stderr'].includes(value))
394
+ return undefined;
395
+ return 'A path outside the project requires explicit approval in guarded auto.';
396
+ }
397
+ if (value.startsWith('~')) {
398
+ if (configSubtree(value))
399
+ return undefined;
400
+ return 'A path outside the project requires explicit approval in guarded auto.';
401
+ }
402
+ if (hasDotDot(value))
403
+ return 'A path outside the project requires explicit approval in guarded auto.';
404
+ if (!value.includes('/'))
405
+ return undefined;
406
+ return await resolvesInsideProject(value, root) ? undefined :
407
+ 'A path outside the project requires explicit approval in guarded auto.';
408
+ }
409
+ const ASK_NETWORK = 'Network access (downloads, uploads, or remote sessions) requires explicit approval in guarded auto.';
410
+ const ASK_INSTALL = 'Package installation or management requires explicit approval in guarded auto.';
411
+ const ASK_SUDO = 'Privilege escalation (sudo/su) requires explicit approval in guarded auto.';
412
+ const ASK_GIT = 'A destructive Git operation requires explicit approval in guarded auto.';
413
+ const ASK_OPAQUE = 'The command uses shell syntax that cannot be classified safely; explicit approval is required.';
414
+ function installReason(executable, args) {
415
+ if (PACKAGE_MANAGERS.has(executable))
416
+ return ASK_INSTALL;
417
+ if (NPM_LIKE.has(executable))
418
+ return args.some((arg) => NPM_INSTALL.has(arg.text)) ? ASK_INSTALL : undefined;
419
+ const subcommands = SUBCOMMAND_INSTALL[executable];
420
+ if (subcommands) {
421
+ const sub = firstOperand(args);
422
+ return sub && subcommands.has(sub) ? ASK_INSTALL : undefined;
423
+ }
424
+ return undefined;
425
+ }
426
+ function destructiveGitReason(args) {
427
+ const sub = firstOperand(args, GIT_VALUE_FLAGS);
428
+ if (!sub || !GIT_DESTRUCTIVE.has(sub))
429
+ return undefined;
430
+ const flags = args.map((arg) => arg.text);
431
+ if (sub === 'reset' && flags.some((flag) => flag === '--hard' || flag === '--merge' || flag === '--keep'))
432
+ return ASK_GIT;
433
+ if (sub === 'clean' && flags.some((flag) => /^-[a-zA-Z]*f/.test(flag)))
434
+ return ASK_GIT;
435
+ if (sub === 'branch' && flags.some((flag) => /^-[a-zA-Z]*D/.test(flag)))
436
+ return ASK_GIT;
437
+ return undefined;
438
+ }
439
+ /** Classify one parsed segment; returns the reason it must ask, if any. */
440
+ async function classifySegment(segment, root) {
441
+ const words = effectiveWords(segment);
442
+ if (!words.length)
443
+ return undefined;
444
+ const command = words[0];
445
+ const executable = path.basename(command.text);
446
+ if (command.dynamic)
447
+ return ASK_OPAQUE;
448
+ if (OPAQUE_COMMANDS.has(executable))
449
+ return ASK_OPAQUE;
450
+ if (SUDO_TOOLS.has(executable))
451
+ return ASK_SUDO;
452
+ if (NETWORK_TOOLS.has(executable))
453
+ return ASK_NETWORK;
454
+ if (executable === 'git') {
455
+ const sub = firstOperand(words.slice(1), GIT_VALUE_FLAGS);
456
+ if (sub && GIT_NETWORK.has(sub))
457
+ return ASK_NETWORK;
458
+ const destructive = destructiveGitReason(words.slice(1));
459
+ if (destructive)
460
+ return destructive;
461
+ }
462
+ const install = installReason(executable, words.slice(1));
463
+ if (install)
464
+ return install;
465
+ const outside = await outsideReason(command.text, root, false);
466
+ if (outside)
467
+ return outside;
468
+ // Argument and redirect-target paths, plus expansions that cannot be resolved.
469
+ for (let i = 1; i < words.length; i++) {
470
+ const word = words[i];
471
+ if (word.redirect) {
472
+ const target = words[i + 1];
473
+ if (!target)
474
+ return ASK_OPAQUE;
475
+ const targetOutside = await outsideReason(target.text, root, true);
476
+ if (targetOutside)
477
+ return targetOutside;
478
+ i++;
479
+ continue;
480
+ }
481
+ if (word.text.startsWith('-') && word.text !== '-')
482
+ continue;
483
+ if (word.dynamic)
484
+ return ASK_OPAQUE;
485
+ const pathOutside = await outsideReason(word.text, root, false);
486
+ if (pathOutside)
487
+ return pathOutside;
488
+ }
489
+ return undefined;
490
+ }
491
+ /** Classify a full command string, following literal `sh -c '…'` bodies so a
492
+ * wrapped category cannot hide behind a shell sub-invocation. */
493
+ async function classifyCommand(command, root, depth = 0) {
494
+ if (depth > 3)
495
+ return undefined;
496
+ const parsed = parseWords(command);
497
+ if (parsed.opaque)
498
+ return `Guarded auto cannot inspect ${parsed.opaque}; explicit approval is required.`;
499
+ for (const segment of parsed.segments) {
500
+ const words = effectiveWords(segment);
501
+ if (!words.length)
502
+ continue;
503
+ const executable = path.basename(words[0].text);
504
+ if (SHELLS.has(executable)) {
505
+ const args = words.slice(1);
506
+ const index = args.findIndex((word) => /^-[a-z]*c[a-z]*$/.test(word.text));
507
+ if (index >= 0 && args[index + 1]) {
508
+ const nested = await classifyCommand(args[index + 1].text, root, depth + 1);
509
+ if (nested)
510
+ return nested;
511
+ continue; // the body is clean; the shell invocation itself adds no path risk
512
+ }
513
+ }
514
+ const ask = await classifySegment(segment, root);
515
+ if (ask)
516
+ return ask;
517
+ }
518
+ return undefined;
519
+ }
520
+ /** Guarded-auto decision: allow by default, ask for the five risk categories, and
521
+ * refuse obviously catastrophic deletion/disk commands outright. Rechecked at
522
+ * execution time; nothing is cached. Never a sandbox or an executable verifier. */
523
+ export async function commandPolicy(command, cwd) {
524
+ try {
525
+ if (typeof command !== 'string' || typeof cwd !== 'string' || !command.trim()) {
526
+ return { auto: false, reason: ASK_OPAQUE };
527
+ }
528
+ const root = await fs.realpath(cwd);
529
+ if (!(await fs.stat(root)).isDirectory() || command.length > 65536) {
530
+ return { auto: false, reason: ASK_OPAQUE };
531
+ }
532
+ // Obviously catastrophic deletion/disk commands are refused outright, not offered.
533
+ const catastrophic = absoluteCommandRisk(command, root);
534
+ if (catastrophic)
535
+ return { auto: false, reason: `Guarded auto refuses obviously catastrophic operations: ${catastrophic}`, deny: true };
536
+ const ask = await classifyCommand(command, root);
537
+ if (ask)
538
+ return { auto: false, reason: ask };
539
+ return { ...ALLOW };
540
+ }
541
+ catch {
542
+ return { auto: false, reason: ASK_OPAQUE };
543
+ }
544
+ }
@@ -0,0 +1,29 @@
1
+ // One catalog for the command palette; no command text is sent to the agent.
2
+ export const COMMANDS = [
3
+ { id: '/model', description: 'Choose a model' },
4
+ { id: '/compact', description: 'Summarize older conversation context' },
5
+ { id: '/settings', description: 'Update API key, memory and default preferences' },
6
+ { id: '/sessions', description: 'Browse and resume saved conversations' },
7
+ { id: '/effort', description: 'Choose reasoning effort' },
8
+ { id: '/mode', description: 'Permissions: ask, files, guarded auto, or unguarded auto' },
9
+ { id: '/undo', description: 'Revert the previous turn’s file changes' },
10
+ { id: '/clear', description: 'Start a fresh conversation' },
11
+ { id: '/exit', description: 'Exit flashlite' },
12
+ { id: '/help', description: 'Shortcuts, permissions, and recovery' },
13
+ { id: '/agents', description: 'Spawn, inspect or stop subagents' },
14
+ ];
15
+ // Custom slash commands (discovered from the user/project) are merged in at runtime.
16
+ let paletteCommands = [];
17
+ /** Register additional palette commands (custom slash commands); idempotent. */
18
+ export function registerPaletteCommands(entries) {
19
+ paletteCommands = entries;
20
+ }
21
+ export function matchingCommands(text) {
22
+ if (!text.startsWith('/') || /\s/.test(text))
23
+ return [];
24
+ const combined = [...COMMANDS, ...paletteCommands];
25
+ const matches = combined.filter((command) => command.id.startsWith(text.toLowerCase()));
26
+ // /mode is also a prefix of /model: a fully typed command must win.
27
+ const exact = matches.find((command) => command.id === text.toLowerCase());
28
+ return exact ? [exact, ...matches.filter((command) => command !== exact)] : matches;
29
+ }
@@ -0,0 +1,66 @@
1
+ export const MAX_COMPACTION_SUMMARY_CHARS = 12_000;
2
+ const RETAIN_RECENT_EXCHANGES = 2;
3
+ /** Locate only complete assistant exchanges; an orphan/pending/duplicate tool blocks compaction. */
4
+ function completeExchanges(history) {
5
+ const exchanges = [];
6
+ for (let i = 0; i < history.length; i++) {
7
+ const message = history[i];
8
+ if (message.role === 'tool')
9
+ return null;
10
+ const calls = message.toolCalls ?? [];
11
+ if (calls.length && message.role !== 'assistant')
12
+ return null;
13
+ if (message.role !== 'assistant')
14
+ continue;
15
+ const start = i;
16
+ const pending = new Set();
17
+ for (const call of calls) {
18
+ if (!call.id.trim() || pending.has(call.id))
19
+ return null;
20
+ pending.add(call.id);
21
+ }
22
+ for (let result = 0; result < calls.length; result++) {
23
+ const next = history[++i];
24
+ if (next?.role !== 'tool' || !next.toolCallId || !pending.delete(next.toolCallId))
25
+ return null;
26
+ }
27
+ exchanges.push(start);
28
+ }
29
+ return exchanges;
30
+ }
31
+ /**
32
+ * Summarize complete older work, retaining current instructions and the latest two
33
+ * exchanges verbatim (including signed thinking). keepFrom identifies the active
34
+ * task's user message; zero is valid for a long first task. No tool pair is split.
35
+ */
36
+ export function compactionParts(history, keepFrom) {
37
+ const exchanges = completeExchanges(history);
38
+ if (!exchanges)
39
+ return null;
40
+ const userTurns = history.flatMap((message, index) => message.role === 'user' ? [index] : []);
41
+ const boundary = keepFrom ?? userTurns.at(-1);
42
+ if (boundary === undefined || !Number.isInteger(boundary) || boundary < 0 || boundary >= history.length || history[boundary].role !== 'user')
43
+ return null;
44
+ const active = exchanges.filter((index) => index > boundary);
45
+ // Explicit active-task selection can compact within the task; manual compaction
46
+ // retains the whole latest user turn when an older turn is available.
47
+ if ((keepFrom !== undefined || boundary === 0) && active.length > RETAIN_RECENT_EXCHANGES) {
48
+ const cut = active[active.length - RETAIN_RECENT_EXCHANGES];
49
+ const instructions = history.slice(boundary, cut).filter((message) => message.role === 'user');
50
+ return { prefix: history.slice(0, cut), tail: [...instructions, ...history.slice(cut)] };
51
+ }
52
+ if (boundary === 0)
53
+ return null;
54
+ return { prefix: history.slice(0, boundary), tail: history.slice(boundary) };
55
+ }
56
+ export function compactedHistory(summary, tail, includeTail = true) {
57
+ if (!includeTail)
58
+ return [{ role: 'user', content: `Earlier conversation summary (context only):\n${summary}` }];
59
+ if (!tail.length || tail[0].role !== 'user')
60
+ throw new Error('compaction tail must begin at a user turn');
61
+ const first = tail[0];
62
+ return [{ ...first, content: `Earlier conversation summary (context only):\n${summary}\n\nLatest user message:\n${first.content}` }, ...tail.slice(1)];
63
+ }
64
+ export function validCompactionSummary(value) {
65
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= MAX_COMPACTION_SUMMARY_CHARS;
66
+ }