@lucenaone/coder 1.1.5 → 1.1.6
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 +70 -21
- 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.6",
|
|
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,21 @@
|
|
|
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
14
|
'dist', 'build', '.cache', '.turbo', '.vercel', '.firebase'
|
|
14
15
|
];
|
|
15
16
|
|
|
17
|
+
const SEARCH_GLOB = '*.{js,jsx,ts,tsx,json,md,css,html,py,rb,go,rs}';
|
|
18
|
+
|
|
16
19
|
// ── The CLI Jailer ──
|
|
17
20
|
function getJailedPath(baseDir, rawPath) {
|
|
18
21
|
let p = rawPath.replace(/\\/g, '/');
|
|
@@ -59,8 +62,15 @@ export class LucenaAgent {
|
|
|
59
62
|
this.watcher = null;
|
|
60
63
|
this.activeCommands = new Map();
|
|
61
64
|
this.connected = false;
|
|
65
|
+
this.stripCwd = true; // Default: strip absolute paths (Browser Mode safety)
|
|
62
66
|
this.indexData = null; // Pre-built index from CLI-side parsing
|
|
63
67
|
this.indexPromise = null; // The in-flight indexing promise
|
|
68
|
+
this.shell = new LucenaShell(this.cwd);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Conditionally strip cwd — only in Browser Mode */
|
|
72
|
+
_sanitize(text) {
|
|
73
|
+
return this.stripCwd ? stripCwd(this.cwd, text) : text;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
_scaffoldWorkspaceBrain() {
|
|
@@ -129,6 +139,11 @@ export class LucenaAgent {
|
|
|
129
139
|
if (!data) return;
|
|
130
140
|
remove(snapshot.ref);
|
|
131
141
|
|
|
142
|
+
// Browser tells us whether to strip cwd from output
|
|
143
|
+
if (typeof data.stripCwd === 'boolean') {
|
|
144
|
+
this.stripCwd = data.stripCwd;
|
|
145
|
+
}
|
|
146
|
+
|
|
132
147
|
// Index is guaranteed ready (awaited in start()), push immediately
|
|
133
148
|
if (this.indexData) {
|
|
134
149
|
this.pushIndexSnapshot(this.indexData);
|
|
@@ -144,7 +159,7 @@ export class LucenaAgent {
|
|
|
144
159
|
try {
|
|
145
160
|
await this.handleCommand(command);
|
|
146
161
|
} catch (err) {
|
|
147
|
-
this.pushResponse(command.messageId, 'error',
|
|
162
|
+
this.pushResponse(command.messageId, 'error', this._sanitize(err.message));
|
|
148
163
|
}
|
|
149
164
|
});
|
|
150
165
|
|
|
@@ -216,15 +231,26 @@ export class LucenaAgent {
|
|
|
216
231
|
});
|
|
217
232
|
}
|
|
218
233
|
|
|
219
|
-
async executeCommand({ messageId, command }) {
|
|
220
|
-
|
|
234
|
+
async executeCommand({ messageId, command, mode = 'safe', approved = false, outsideWorkspaceApproved = false }) {
|
|
235
|
+
let child;
|
|
221
236
|
|
|
222
|
-
|
|
223
|
-
this.
|
|
237
|
+
try {
|
|
238
|
+
child = this.shell.execute(command, { mode, approved, outsideWorkspaceApproved });
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
child.stdout?.on('data', (data) => {
|
|
244
|
+
this.pushResponse(messageId, 'output', this._sanitize(data.toString()));
|
|
224
245
|
});
|
|
225
|
-
child.stderr
|
|
226
|
-
this.pushResponse(messageId, 'output',
|
|
246
|
+
child.stderr?.on('data', (data) => {
|
|
247
|
+
this.pushResponse(messageId, 'output', this._sanitize(data.toString()));
|
|
227
248
|
});
|
|
249
|
+
|
|
250
|
+
// Immediately close stdin so commands that try to read it get EOF
|
|
251
|
+
// instead of hanging until the 60s timeout
|
|
252
|
+
try { child.stdin?.end(); } catch { /* already closed */ }
|
|
253
|
+
|
|
228
254
|
child.on('close', (code) => {
|
|
229
255
|
this.pushResponse(messageId, 'done', '', { exitCode: code ?? 0 });
|
|
230
256
|
this.activeCommands.delete(messageId);
|
|
@@ -240,7 +266,7 @@ export class LucenaAgent {
|
|
|
240
266
|
this.pushResponse(messageId, 'output', content);
|
|
241
267
|
this.pushResponse(messageId, 'done', '');
|
|
242
268
|
} catch (err) {
|
|
243
|
-
this.pushResponse(messageId, 'error',
|
|
269
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
244
270
|
}
|
|
245
271
|
}
|
|
246
272
|
|
|
@@ -252,7 +278,7 @@ export class LucenaAgent {
|
|
|
252
278
|
await writeFile(fullPath, content, 'utf-8');
|
|
253
279
|
this.pushResponse(messageId, 'done', `Wrote ${relPath}`);
|
|
254
280
|
} catch (err) {
|
|
255
|
-
this.pushResponse(messageId, 'error',
|
|
281
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
256
282
|
}
|
|
257
283
|
}
|
|
258
284
|
|
|
@@ -266,7 +292,7 @@ export class LucenaAgent {
|
|
|
266
292
|
.join('\n');
|
|
267
293
|
this.pushResponse(messageId, 'done', listing || '(empty)');
|
|
268
294
|
} catch (err) {
|
|
269
|
-
this.pushResponse(messageId, 'error',
|
|
295
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
270
296
|
}
|
|
271
297
|
}
|
|
272
298
|
|
|
@@ -282,7 +308,7 @@ export class LucenaAgent {
|
|
|
282
308
|
created: s.birthtime
|
|
283
309
|
}));
|
|
284
310
|
} catch (err) {
|
|
285
|
-
this.pushResponse(messageId, 'error',
|
|
311
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
286
312
|
}
|
|
287
313
|
}
|
|
288
314
|
|
|
@@ -297,7 +323,7 @@ export class LucenaAgent {
|
|
|
297
323
|
}
|
|
298
324
|
this.pushResponse(messageId, 'done', `Deleted ${relPath}`);
|
|
299
325
|
} catch (err) {
|
|
300
|
-
this.pushResponse(messageId, 'error',
|
|
326
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
301
327
|
}
|
|
302
328
|
}
|
|
303
329
|
|
|
@@ -308,30 +334,53 @@ export class LucenaAgent {
|
|
|
308
334
|
await mkdir(fullPath, { recursive: true });
|
|
309
335
|
this.pushResponse(messageId, 'done', `Created ${relPath}`);
|
|
310
336
|
} catch (err) {
|
|
311
|
-
this.pushResponse(messageId, 'error',
|
|
337
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
312
338
|
}
|
|
313
339
|
}
|
|
314
340
|
|
|
315
341
|
async searchCodebase({ messageId, query, directory }) {
|
|
316
342
|
const searchDir = getJailedPath(this.cwd, directory || '.');
|
|
317
343
|
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 });
|
|
344
|
+
const child = this._createSearchProcess(query, searchDir);
|
|
322
345
|
|
|
323
346
|
let output = '';
|
|
324
347
|
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
325
348
|
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
326
349
|
|
|
327
350
|
child.on('close', (code) => {
|
|
328
|
-
this.pushResponse(messageId, 'done',
|
|
351
|
+
this.pushResponse(messageId, 'done', this._sanitize(output) || 'No matches found');
|
|
352
|
+
});
|
|
353
|
+
child.on('error', (err) => {
|
|
354
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
329
355
|
});
|
|
330
356
|
} catch (err) {
|
|
331
|
-
this.pushResponse(messageId, 'error',
|
|
357
|
+
this.pushResponse(messageId, 'error', this._sanitize(err.message));
|
|
332
358
|
}
|
|
333
359
|
}
|
|
334
360
|
|
|
361
|
+
_createSearchProcess(query, searchDir) {
|
|
362
|
+
try {
|
|
363
|
+
const rg = spawnSync('rg', ['--version'], { cwd: this.cwd, encoding: 'utf8' });
|
|
364
|
+
if (rg.status === 0) {
|
|
365
|
+
return spawn('rg', ['-n', '--glob', SEARCH_GLOB, query, searchDir], { cwd: this.cwd });
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// Fall back to the OS-native search tool below.
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (process.platform === 'win32') {
|
|
372
|
+
return spawn('findstr', ['/s', '/n', `/c:${query}`, join(searchDir, '*')], {
|
|
373
|
+
cwd: this.cwd,
|
|
374
|
+
shell: true,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return spawn('grep', [
|
|
379
|
+
'-rn', `--include=${SEARCH_GLOB}`,
|
|
380
|
+
query, searchDir
|
|
381
|
+
], { cwd: this.cwd });
|
|
382
|
+
}
|
|
383
|
+
|
|
335
384
|
startWatcher() {
|
|
336
385
|
this.watcher = watch(this.cwd, {
|
|
337
386
|
ignored: (path) => IGNORED_PATTERNS.some(p => path.includes(p)),
|
|
@@ -376,4 +425,4 @@ export class LucenaAgent {
|
|
|
376
425
|
|
|
377
426
|
this.connected = false;
|
|
378
427
|
}
|
|
379
|
-
}
|
|
428
|
+
}
|
|
@@ -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
|
+
}
|