@lucenaone/coder 1.1.5 → 1.1.16
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 +9 -1
- package/src/agent.js +135 -28
- package/src/lucena-shell.js +303 -0
- package/src/main.js +3 -3
- package/.WorkspaceBrain/workspace.json +0 -4
package/package.json
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lucenaone/coder",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.16",
|
|
4
4
|
"description": "Private tunnel for connecting LucenaCoder.com to your local folder. Always remains folder scoped while providing full terminal access.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"lucenacoder": "./bin/lucena.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"grammars",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
9
15
|
"scripts": {
|
|
10
16
|
"start": "node bin/lucena.js"
|
|
11
17
|
},
|
|
12
18
|
"dependencies": {
|
|
13
19
|
"chokidar": "^4.0.0",
|
|
20
|
+
"execa": "^9.6.1",
|
|
14
21
|
"firebase": "^11.0.0",
|
|
22
|
+
"shell-quote": "^1.8.4",
|
|
15
23
|
"web-tree-sitter": "^0.26.9"
|
|
16
24
|
},
|
|
17
25
|
"keywords": [
|
package/src/agent.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { initializeApp } from 'firebase/app';
|
|
2
2
|
import { getDatabase, ref, push, set, onChildAdded, onDisconnect, serverTimestamp, remove, get } from 'firebase/database';
|
|
3
|
-
import { spawn } from 'child_process';
|
|
3
|
+
import { spawn, spawnSync } from 'child_process';
|
|
4
4
|
import { watch } from 'chokidar';
|
|
5
5
|
import { readFile, writeFile, mkdir, readdir, stat, unlink, rm } from 'fs/promises';
|
|
6
6
|
import { join, resolve, dirname, basename, relative, isAbsolute, extname } from 'path';
|
|
7
7
|
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
8
8
|
import { FIREBASE_CONFIG } from './config.js';
|
|
9
9
|
import { buildIndex, reindexFile } from './cli-indexer.js';
|
|
10
|
+
import { LucenaShell } from './lucena-shell.js';
|
|
10
11
|
|
|
11
12
|
const IGNORED_PATTERNS = [
|
|
12
13
|
'node_modules', '.git', '.next', '.wrangler', '.DS_Store',
|
|
13
|
-
'dist', 'build', '.cache', '.turbo', '.vercel', '.firebase'
|
|
14
|
+
'dist', 'build', '.cache', '.turbo', '.vercel', '.firebase',
|
|
15
|
+
'.venv', 'venv', '__pycache__', '.pytest_cache', '.mypy_cache',
|
|
16
|
+
'coverage', '.coverage', 'target', 'out', '.gradle'
|
|
14
17
|
];
|
|
15
18
|
|
|
19
|
+
const SEARCH_GLOB = '*.{js,jsx,ts,tsx,json,md,css,html,py,rb,go,rs}';
|
|
20
|
+
|
|
16
21
|
// ── The CLI Jailer ──
|
|
17
22
|
function getJailedPath(baseDir, rawPath) {
|
|
18
23
|
let p = rawPath.replace(/\\/g, '/');
|
|
@@ -59,8 +64,15 @@ export class LucenaAgent {
|
|
|
59
64
|
this.watcher = null;
|
|
60
65
|
this.activeCommands = new Map();
|
|
61
66
|
this.connected = false;
|
|
67
|
+
this.stripCwd = true; // Default: strip absolute paths (Browser Mode safety)
|
|
62
68
|
this.indexData = null; // Pre-built index from CLI-side parsing
|
|
63
69
|
this.indexPromise = null; // The in-flight indexing promise
|
|
70
|
+
this.shell = new LucenaShell(this.cwd);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Conditionally strip cwd — only in Browser Mode */
|
|
74
|
+
_sanitize(text) {
|
|
75
|
+
return this.stripCwd ? stripCwd(this.cwd, text) : text;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
_scaffoldWorkspaceBrain() {
|
|
@@ -129,6 +141,11 @@ export class LucenaAgent {
|
|
|
129
141
|
if (!data) return;
|
|
130
142
|
remove(snapshot.ref);
|
|
131
143
|
|
|
144
|
+
// Browser tells us whether to strip cwd from output
|
|
145
|
+
if (typeof data.stripCwd === 'boolean') {
|
|
146
|
+
this.stripCwd = data.stripCwd;
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
// Index is guaranteed ready (awaited in start()), push immediately
|
|
133
150
|
if (this.indexData) {
|
|
134
151
|
this.pushIndexSnapshot(this.indexData);
|
|
@@ -144,7 +161,7 @@ export class LucenaAgent {
|
|
|
144
161
|
try {
|
|
145
162
|
await this.handleCommand(command);
|
|
146
163
|
} catch (err) {
|
|
147
|
-
this.pushResponse(command.messageId, 'error',
|
|
164
|
+
this.pushResponse(command.messageId, 'error', this._sanitize(err.message));
|
|
148
165
|
}
|
|
149
166
|
});
|
|
150
167
|
|
|
@@ -195,6 +212,7 @@ export class LucenaAgent {
|
|
|
195
212
|
case 'execute': return this.executeCommand(command);
|
|
196
213
|
case 'read_file': return this.readFileCmd(command);
|
|
197
214
|
case 'write_file': return this.writeFileCmd(command);
|
|
215
|
+
case 'list_files': return this.listFiles(command);
|
|
198
216
|
case 'list_dir': return this.listDir(command);
|
|
199
217
|
case 'stat': return this.statFile(command);
|
|
200
218
|
case 'delete_file': return this.deleteFile(command);
|
|
@@ -216,15 +234,26 @@ export class LucenaAgent {
|
|
|
216
234
|
});
|
|
217
235
|
}
|
|
218
236
|
|
|
219
|
-
async executeCommand({ messageId, command }) {
|
|
220
|
-
|
|
237
|
+
async executeCommand({ messageId, command, mode = 'safe', approved = false, outsideWorkspaceApproved = false }) {
|
|
238
|
+
let child;
|
|
221
239
|
|
|
222
|
-
|
|
223
|
-
this.
|
|
240
|
+
try {
|
|
241
|
+
child = this.shell.execute(command, { mode, approved, outsideWorkspaceApproved });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
child.stdout?.on('data', (data) => {
|
|
247
|
+
this.pushResponse(messageId, 'output', this._sanitize(data.toString()));
|
|
224
248
|
});
|
|
225
|
-
child.stderr
|
|
226
|
-
this.pushResponse(messageId, 'output',
|
|
249
|
+
child.stderr?.on('data', (data) => {
|
|
250
|
+
this.pushResponse(messageId, 'output', this._sanitize(data.toString()));
|
|
227
251
|
});
|
|
252
|
+
|
|
253
|
+
// Immediately close stdin so commands that try to read it get EOF
|
|
254
|
+
// instead of hanging until the 60s timeout
|
|
255
|
+
try { child.stdin?.end(); } catch { /* already closed */ }
|
|
256
|
+
|
|
228
257
|
child.on('close', (code) => {
|
|
229
258
|
this.pushResponse(messageId, 'done', '', { exitCode: code ?? 0 });
|
|
230
259
|
this.activeCommands.delete(messageId);
|
|
@@ -240,19 +269,19 @@ export class LucenaAgent {
|
|
|
240
269
|
this.pushResponse(messageId, 'output', content);
|
|
241
270
|
this.pushResponse(messageId, 'done', '');
|
|
242
271
|
} catch (err) {
|
|
243
|
-
this.pushResponse(messageId, 'error',
|
|
272
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
244
273
|
}
|
|
245
274
|
}
|
|
246
275
|
|
|
247
276
|
async writeFileCmd({ messageId, path: filePath, content }) {
|
|
248
277
|
const fullPath = getJailedPath(this.cwd, filePath);
|
|
249
|
-
const relPath =
|
|
278
|
+
const relPath = toBrowserPath(relative(this.cwd, fullPath));
|
|
250
279
|
try {
|
|
251
280
|
await mkdir(dirname(fullPath), { recursive: true });
|
|
252
281
|
await writeFile(fullPath, content, 'utf-8');
|
|
253
282
|
this.pushResponse(messageId, 'done', `Wrote ${relPath}`);
|
|
254
283
|
} catch (err) {
|
|
255
|
-
this.pushResponse(messageId, 'error',
|
|
284
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
256
285
|
}
|
|
257
286
|
}
|
|
258
287
|
|
|
@@ -266,8 +295,40 @@ export class LucenaAgent {
|
|
|
266
295
|
.join('\n');
|
|
267
296
|
this.pushResponse(messageId, 'done', listing || '(empty)');
|
|
268
297
|
} catch (err) {
|
|
269
|
-
this.pushResponse(messageId, 'error',
|
|
298
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async listFiles({ messageId }) {
|
|
303
|
+
try {
|
|
304
|
+
const files = await this.walkFiles(this.cwd);
|
|
305
|
+
this.pushResponse(messageId, 'done', JSON.stringify(files));
|
|
306
|
+
} catch (err) {
|
|
307
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async walkFiles(dirPath, relativeDir = '') {
|
|
312
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
313
|
+
const files = [];
|
|
314
|
+
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
if (IGNORED_PATTERNS.includes(entry.name)) continue;
|
|
317
|
+
|
|
318
|
+
const relPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
319
|
+
const fullPath = join(dirPath, entry.name);
|
|
320
|
+
|
|
321
|
+
if (entry.isDirectory()) {
|
|
322
|
+
files.push(...await this.walkFiles(fullPath, relPath));
|
|
323
|
+
} else if (entry.isFile()) {
|
|
324
|
+
files.push({
|
|
325
|
+
path: relPath,
|
|
326
|
+
lineCount: await countTextFileLines(fullPath),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
270
329
|
}
|
|
330
|
+
|
|
331
|
+
return files;
|
|
271
332
|
}
|
|
272
333
|
|
|
273
334
|
async statFile({ messageId, path: filePath }) {
|
|
@@ -282,13 +343,13 @@ export class LucenaAgent {
|
|
|
282
343
|
created: s.birthtime
|
|
283
344
|
}));
|
|
284
345
|
} catch (err) {
|
|
285
|
-
this.pushResponse(messageId, 'error',
|
|
346
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
286
347
|
}
|
|
287
348
|
}
|
|
288
349
|
|
|
289
350
|
async deleteFile({ messageId, path: filePath }) {
|
|
290
351
|
const fullPath = getJailedPath(this.cwd, filePath);
|
|
291
|
-
const relPath =
|
|
352
|
+
const relPath = toBrowserPath(relative(this.cwd, fullPath));
|
|
292
353
|
try {
|
|
293
354
|
if ((await stat(fullPath)).isDirectory()) {
|
|
294
355
|
await rm(fullPath, { recursive: true });
|
|
@@ -297,41 +358,64 @@ export class LucenaAgent {
|
|
|
297
358
|
}
|
|
298
359
|
this.pushResponse(messageId, 'done', `Deleted ${relPath}`);
|
|
299
360
|
} catch (err) {
|
|
300
|
-
this.pushResponse(messageId, 'error',
|
|
361
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
301
362
|
}
|
|
302
363
|
}
|
|
303
364
|
|
|
304
365
|
async mkdirCmd({ messageId, path: dirPath }) {
|
|
305
366
|
const fullPath = getJailedPath(this.cwd, dirPath);
|
|
306
|
-
const relPath =
|
|
367
|
+
const relPath = toBrowserPath(relative(this.cwd, fullPath));
|
|
307
368
|
try {
|
|
308
369
|
await mkdir(fullPath, { recursive: true });
|
|
309
370
|
this.pushResponse(messageId, 'done', `Created ${relPath}`);
|
|
310
371
|
} catch (err) {
|
|
311
|
-
this.pushResponse(messageId, 'error',
|
|
372
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
312
373
|
}
|
|
313
374
|
}
|
|
314
375
|
|
|
315
376
|
async searchCodebase({ messageId, query, directory }) {
|
|
316
377
|
const searchDir = getJailedPath(this.cwd, directory || '.');
|
|
317
378
|
try {
|
|
318
|
-
const child =
|
|
319
|
-
'-rn', '--include=*.{js,jsx,ts,tsx,json,md,css,html,py,rb,go,rs}',
|
|
320
|
-
query, searchDir
|
|
321
|
-
], { cwd: this.cwd });
|
|
379
|
+
const child = this._createSearchProcess(query, searchDir);
|
|
322
380
|
|
|
323
381
|
let output = '';
|
|
324
382
|
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
325
383
|
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
326
384
|
|
|
327
385
|
child.on('close', (code) => {
|
|
328
|
-
this.pushResponse(messageId, 'done',
|
|
386
|
+
this.pushResponse(messageId, 'done', this._sanitize(output) || 'No matches found');
|
|
387
|
+
});
|
|
388
|
+
child.on('error', (err) => {
|
|
389
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
329
390
|
});
|
|
330
391
|
} catch (err) {
|
|
331
|
-
this.pushResponse(messageId, 'error',
|
|
392
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
332
393
|
}
|
|
333
394
|
}
|
|
334
395
|
|
|
396
|
+
_createSearchProcess(query, searchDir) {
|
|
397
|
+
try {
|
|
398
|
+
const rg = spawnSync('rg', ['--version'], { cwd: this.cwd, encoding: 'utf8' });
|
|
399
|
+
if (rg.status === 0) {
|
|
400
|
+
return spawn('rg', ['-n', '--glob', SEARCH_GLOB, query, searchDir], { cwd: this.cwd });
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
// Fall back to the OS-native search tool below.
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (process.platform === 'win32') {
|
|
407
|
+
return spawn('findstr', ['/s', '/n', `/c:${query}`, join(searchDir, '*')], {
|
|
408
|
+
cwd: this.cwd,
|
|
409
|
+
shell: true,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return spawn('grep', [
|
|
414
|
+
'-rn', `--include=${SEARCH_GLOB}`,
|
|
415
|
+
query, searchDir
|
|
416
|
+
], { cwd: this.cwd });
|
|
417
|
+
}
|
|
418
|
+
|
|
335
419
|
startWatcher() {
|
|
336
420
|
this.watcher = watch(this.cwd, {
|
|
337
421
|
ignored: (path) => IGNORED_PATTERNS.some(p => path.includes(p)),
|
|
@@ -341,22 +425,23 @@ export class LucenaAgent {
|
|
|
341
425
|
|
|
342
426
|
this.watcher.on('all', (event, filePath) => {
|
|
343
427
|
const relPath = relative(this.cwd, filePath);
|
|
428
|
+
const normalizedRelPath = relPath.replace(/\\/g, '/');
|
|
344
429
|
|
|
345
430
|
// Stop anything escaping local disk
|
|
346
|
-
if (!
|
|
431
|
+
if (!normalizedRelPath || normalizedRelPath.startsWith('..') || isAbsolute(relPath)) {
|
|
347
432
|
return;
|
|
348
433
|
}
|
|
349
434
|
|
|
350
435
|
const changesRef = ref(this.db, `tunnels/${this.tunnelId}/fileChanges`);
|
|
351
436
|
push(changesRef, {
|
|
352
437
|
event,
|
|
353
|
-
path:
|
|
438
|
+
path: normalizedRelPath,
|
|
354
439
|
timestamp: serverTimestamp()
|
|
355
440
|
});
|
|
356
441
|
|
|
357
442
|
// ── Push incremental index delta for changed files ──
|
|
358
443
|
if (event === 'change' || event === 'add') {
|
|
359
|
-
this.pushIndexDelta(
|
|
444
|
+
this.pushIndexDelta(normalizedRelPath).catch(() => {}); // Non-blocking, non-fatal
|
|
360
445
|
}
|
|
361
446
|
});
|
|
362
447
|
}
|
|
@@ -376,4 +461,26 @@ export class LucenaAgent {
|
|
|
376
461
|
|
|
377
462
|
this.connected = false;
|
|
378
463
|
}
|
|
379
|
-
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function toBrowserPath(pathValue) {
|
|
467
|
+
return '/' + String(pathValue || '').replace(/\\/g, '/');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function countTextFileLines(fullPath) {
|
|
471
|
+
if (!isTextLikeFile(fullPath)) return null;
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const info = await stat(fullPath);
|
|
475
|
+
if (info.size > 5 * 1024 * 1024) return null;
|
|
476
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
477
|
+
if (!content) return 0;
|
|
478
|
+
return content.split('\n').length;
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isTextLikeFile(filePath) {
|
|
485
|
+
return /\.(cjs|css|csv|go|html?|js|jsx|json|mdx?|mjs|py|rb|rs|sql|svg|ts|tsx|txt|xml|ya?ml)$/i.test(filePath);
|
|
486
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { execaCommand } from 'execa';
|
|
2
|
+
import { parse } from 'shell-quote';
|
|
3
|
+
import { isAbsolute, resolve, relative } from 'path';
|
|
4
|
+
|
|
5
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
6
|
+
'awk', 'basename', 'cat', 'cd', 'curl', 'cut', 'dirname', 'echo', 'false', 'find',
|
|
7
|
+
'grep', 'head', 'ls', 'pwd', 'rg', 'sed', 'sort', 'tail', 'test', 'true',
|
|
8
|
+
'uniq', 'wc', 'which',
|
|
9
|
+
'dir', 'type', 'findstr', 'where', 'tree', 'more', 'clip', 'ver', 'vol',
|
|
10
|
+
'hostname', 'systeminfo',
|
|
11
|
+
'get-childitem', 'get-content', 'select-string', 'get-location',
|
|
12
|
+
'get-command', 'get-process', 'get-service', 'get-item', 'get-itemproperty',
|
|
13
|
+
'test-path', 'get-help', 'write-output', 'write-host',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
17
|
+
'branch', 'diff', 'grep', 'log', 'ls-files', 'rev-parse', 'show', 'status',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const CHAIN_OPERATORS = new Set(['&&', '||', ';', '|']);
|
|
21
|
+
const WRITE_OPERATORS = new Set(['>', '>>', '<>', '>|', '<<', '<<-', '<<<']);
|
|
22
|
+
const COMMANDS_WITH_PATH_OPERANDS = new Set([
|
|
23
|
+
'cat', 'chmod', 'chown', 'cp', 'find', 'grep', 'head', 'ls', 'mkdir', 'mv',
|
|
24
|
+
'rm', 'rmdir', 'sed', 'tail', 'touch', 'wc',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export class LucenaShell {
|
|
28
|
+
constructor(workspaceRoot) {
|
|
29
|
+
this.workspaceRoot = resolve(workspaceRoot);
|
|
30
|
+
this.cwd = this.workspaceRoot;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
analyze(rawCommand) {
|
|
34
|
+
const sanitized = sanitizeCommand(rawCommand);
|
|
35
|
+
const analysis = {
|
|
36
|
+
command: sanitized.command,
|
|
37
|
+
rejected: false,
|
|
38
|
+
rejectReason: '',
|
|
39
|
+
isReadOnly: true,
|
|
40
|
+
needsApproval: false,
|
|
41
|
+
touchesOutsideWorkspace: false,
|
|
42
|
+
reasons: [],
|
|
43
|
+
segments: [],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (!sanitized.ok) {
|
|
47
|
+
return {
|
|
48
|
+
...analysis,
|
|
49
|
+
rejected: true,
|
|
50
|
+
rejectReason: sanitized.reason,
|
|
51
|
+
isReadOnly: false,
|
|
52
|
+
needsApproval: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!sanitized.command) return analysis;
|
|
57
|
+
|
|
58
|
+
if (/`|\$\(/.test(sanitized.command)) {
|
|
59
|
+
analysis.isReadOnly = false;
|
|
60
|
+
analysis.needsApproval = true;
|
|
61
|
+
analysis.reasons.push('uses command substitution');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let tokens;
|
|
65
|
+
try {
|
|
66
|
+
tokens = parse(sanitized.command);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return {
|
|
69
|
+
...analysis,
|
|
70
|
+
rejected: true,
|
|
71
|
+
rejectReason: err.message || 'Command could not be parsed.',
|
|
72
|
+
isReadOnly: false,
|
|
73
|
+
needsApproval: true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let current = [];
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
if (isOperator(token) && CHAIN_OPERATORS.has(token.op)) {
|
|
80
|
+
this._addSegmentAnalysis(analysis, current);
|
|
81
|
+
current = [];
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isOperator(token) && WRITE_OPERATORS.has(token.op)) {
|
|
86
|
+
analysis.isReadOnly = false;
|
|
87
|
+
analysis.needsApproval = true;
|
|
88
|
+
analysis.reasons.push(`uses shell redirection (${token.op})`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isOperator(token) && token.op === '&') {
|
|
92
|
+
analysis.isReadOnly = false;
|
|
93
|
+
analysis.needsApproval = true;
|
|
94
|
+
analysis.reasons.push('runs a background process');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
current.push(token);
|
|
98
|
+
}
|
|
99
|
+
this._addSegmentAnalysis(analysis, current);
|
|
100
|
+
|
|
101
|
+
analysis.reasons = [...new Set(analysis.reasons)];
|
|
102
|
+
return analysis;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
canExecute(rawCommand, options = {}) {
|
|
106
|
+
const mode = options.mode === 'yolo' ? 'yolo' : 'safe';
|
|
107
|
+
const approved = options.approved === true;
|
|
108
|
+
const outsideWorkspaceApproved = options.outsideWorkspaceApproved === true;
|
|
109
|
+
const analysis = this.analyze(rawCommand);
|
|
110
|
+
|
|
111
|
+
if (analysis.rejected) {
|
|
112
|
+
return { ok: false, analysis, reason: analysis.rejectReason };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (analysis.isReadOnly) {
|
|
116
|
+
return { ok: true, analysis };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (mode === 'safe' && !approved) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
analysis,
|
|
123
|
+
reason: `Safe Mode blocked a mutating command. Approval is required. ${formatReasons(analysis.reasons)}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (mode === 'yolo' && analysis.touchesOutsideWorkspace && !outsideWorkspaceApproved) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
analysis,
|
|
131
|
+
reason: `YOLO Mode blocked an outside-workspace mutation. Approval is required. ${formatReasons(analysis.reasons)}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { ok: true, analysis };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
execute(rawCommand, options = {}) {
|
|
139
|
+
const decision = this.canExecute(rawCommand, options);
|
|
140
|
+
if (!decision.ok) {
|
|
141
|
+
const err = new Error(decision.reason);
|
|
142
|
+
err.analysis = decision.analysis;
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return execaCommand(decision.analysis.command, {
|
|
147
|
+
cwd: this.cwd,
|
|
148
|
+
shell: true,
|
|
149
|
+
reject: false,
|
|
150
|
+
preferLocal: true,
|
|
151
|
+
stdin: 'pipe',
|
|
152
|
+
env: {
|
|
153
|
+
LUCENA_WORKSPACE_ROOT: this.workspaceRoot,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_addSegmentAnalysis(analysis, tokens) {
|
|
159
|
+
const words = tokens.filter((token) => typeof token === 'string');
|
|
160
|
+
if (words.length === 0) return;
|
|
161
|
+
|
|
162
|
+
let index = 0;
|
|
163
|
+
while (words[index] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
|
|
164
|
+
index++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const command = stripQuotes(words[index] || '').toLowerCase();
|
|
168
|
+
if (!command) return;
|
|
169
|
+
|
|
170
|
+
const segment = {
|
|
171
|
+
command,
|
|
172
|
+
words,
|
|
173
|
+
isReadOnly: true,
|
|
174
|
+
touchesOutsideWorkspace: false,
|
|
175
|
+
};
|
|
176
|
+
analysis.segments.push(segment);
|
|
177
|
+
|
|
178
|
+
if (command === 'cd') {
|
|
179
|
+
const target = words[index + 1] ? stripQuotes(words[index + 1]) : this.workspaceRoot;
|
|
180
|
+
const nextCwd = resolvePath(this.cwd, target);
|
|
181
|
+
if (!isInside(this.workspaceRoot, nextCwd)) {
|
|
182
|
+
segment.touchesOutsideWorkspace = true;
|
|
183
|
+
analysis.touchesOutsideWorkspace = true;
|
|
184
|
+
analysis.reasons.push(`changes directory outside workspace (${displayPath(this.workspaceRoot, nextCwd)})`);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (command === 'git') {
|
|
190
|
+
const subcommand = stripQuotes(words[index + 1] || '').toLowerCase();
|
|
191
|
+
if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) {
|
|
192
|
+
markMutating(analysis, segment, subcommand ? `git ${subcommand} may modify state` : 'git command is incomplete');
|
|
193
|
+
}
|
|
194
|
+
} else if (command === 'curl' && words.slice(index + 1).some(isCurlWriteOption)) {
|
|
195
|
+
markMutating(analysis, segment, 'curl output options may write files');
|
|
196
|
+
} else if (command === 'sed' && words.some((word) => /^-.*i/.test(stripQuotes(word)))) {
|
|
197
|
+
markMutating(analysis, segment, 'sed in-place editing may modify files');
|
|
198
|
+
} else if (!READ_ONLY_COMMANDS.has(command)) {
|
|
199
|
+
markMutating(analysis, segment, `${command} is not classified as read-only`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (segment.isReadOnly) return;
|
|
203
|
+
|
|
204
|
+
const pathOperands = collectPathOperands(command, words.slice(index + 1));
|
|
205
|
+
if (pathOperands.length === 0 && !isInside(this.workspaceRoot, this.cwd)) {
|
|
206
|
+
segment.touchesOutsideWorkspace = true;
|
|
207
|
+
analysis.touchesOutsideWorkspace = true;
|
|
208
|
+
analysis.reasons.push('mutates from a working directory outside the workspace');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const operand of pathOperands) {
|
|
213
|
+
const absolutePath = resolvePath(this.cwd, operand);
|
|
214
|
+
if (!isInside(this.workspaceRoot, absolutePath)) {
|
|
215
|
+
segment.touchesOutsideWorkspace = true;
|
|
216
|
+
analysis.touchesOutsideWorkspace = true;
|
|
217
|
+
analysis.reasons.push(`references outside-workspace path (${displayPath(this.workspaceRoot, absolutePath)})`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sanitizeCommand(rawCommand) {
|
|
224
|
+
const command = String(rawCommand || '').replace(/\r\n?/g, '\n').trim();
|
|
225
|
+
|
|
226
|
+
if (!command) return { ok: true, command };
|
|
227
|
+
if (command.length > 8000) {
|
|
228
|
+
return { ok: false, command, reason: 'Command is too long.' };
|
|
229
|
+
}
|
|
230
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(command)) {
|
|
231
|
+
return { ok: false, command, reason: 'Command contains unsupported control characters.' };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { ok: true, command };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isOperator(token) {
|
|
238
|
+
return token && typeof token === 'object' && typeof token.op === 'string';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function markMutating(analysis, segment, reason) {
|
|
242
|
+
segment.isReadOnly = false;
|
|
243
|
+
analysis.isReadOnly = false;
|
|
244
|
+
analysis.needsApproval = true;
|
|
245
|
+
analysis.reasons.push(reason);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function collectPathOperands(command, args) {
|
|
249
|
+
if (!COMMANDS_WITH_PATH_OPERANDS.has(command)) return [];
|
|
250
|
+
const operands = [];
|
|
251
|
+
|
|
252
|
+
for (let i = 0; i < args.length; i++) {
|
|
253
|
+
const arg = stripQuotes(args[i]);
|
|
254
|
+
if (!arg || arg === '--') continue;
|
|
255
|
+
|
|
256
|
+
if (arg.startsWith('-')) {
|
|
257
|
+
if (optionConsumesNext(command, arg)) i++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (looksLikePath(arg)) operands.push(arg);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return operands;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function optionConsumesNext(command, option) {
|
|
268
|
+
if (command === 'find' && ['-name', '-path', '-type', '-maxdepth', '-mindepth'].includes(option)) return true;
|
|
269
|
+
if (command === 'grep' && ['-e', '-f', '--exclude', '--include'].includes(option)) return true;
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isCurlWriteOption(word) {
|
|
274
|
+
const option = stripQuotes(word);
|
|
275
|
+
return option === '-o' || option === '-O' || option === '-J' || option === '--output' || option === '--remote-name' || option === '--remote-header-name' || option.startsWith('--output=');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function looksLikePath(value) {
|
|
279
|
+
if (!value) return false;
|
|
280
|
+
if (value.includes('*') || value.includes('?') || value.includes('[')) return true;
|
|
281
|
+
return value === '.' || value === '..' || value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.includes('/');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function resolvePath(cwd, value) {
|
|
285
|
+
return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isInside(root, candidate) {
|
|
289
|
+
const rel = relative(root, candidate);
|
|
290
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function displayPath(root, absolutePath) {
|
|
294
|
+
return isInside(root, absolutePath) ? `/${relative(root, absolutePath)}` : absolutePath;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function stripQuotes(value) {
|
|
298
|
+
return String(value).replace(/^["']|["']$/g, '');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function formatReasons(reasons) {
|
|
302
|
+
return reasons.length ? `Reasons: ${reasons.join('; ')}.` : '';
|
|
303
|
+
}
|
package/src/main.js
CHANGED
|
@@ -63,7 +63,7 @@ export async function main() {
|
|
|
63
63
|
const border = '─'.repeat(boxWidth);
|
|
64
64
|
|
|
65
65
|
console.log(` ${c.dim}┌${border}┐${c.reset}`);
|
|
66
|
-
console.log(` ${c.dim}│${c.reset} ${
|
|
66
|
+
console.log(` ${c.dim}│${c.reset} ${idLabel}${c.reset} ${c.bold}${tunnelId}${c.reset} ${c.dim}│${c.reset}`);
|
|
67
67
|
console.log(` ${c.dim}└${border}┘${c.reset}`);
|
|
68
68
|
|
|
69
69
|
const webUrl = `https://lucenacoder.com/?tunnel=${tunnelId}`;
|
|
@@ -73,7 +73,7 @@ export async function main() {
|
|
|
73
73
|
const urlBorder = '─'.repeat(urlBoxWidth);
|
|
74
74
|
|
|
75
75
|
console.log(`\n ${c.dim}┌${urlBorder}┐${c.reset}`);
|
|
76
|
-
console.log(` ${c.dim}│${c.reset} ${
|
|
76
|
+
console.log(` ${c.dim}│${c.reset} ${urlLabel}${c.reset} ${c.bold}${webUrl}${c.reset} ${c.dim}│${c.reset}`);
|
|
77
77
|
console.log(` ${c.dim}└${urlBorder}┘${c.reset}`);
|
|
78
78
|
|
|
79
79
|
console.log(`\n ${c.dim}Open the URL above in your browser to connect.${c.reset}`);
|
|
@@ -82,4 +82,4 @@ export async function main() {
|
|
|
82
82
|
console.error(`\n ${c.yellow}✖ Failed to start tunnel: ${err.message}${c.reset}\n`);
|
|
83
83
|
process.exit(1);
|
|
84
84
|
}
|
|
85
|
-
}
|
|
85
|
+
}
|