opencode-landstrip 0.15.1 → 0.15.3
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/index.ts +25 -16
- package/package.json +3 -3
- package/shared.ts +5 -3
- package/tui.ts +51 -45
package/index.ts
CHANGED
|
@@ -55,7 +55,7 @@ interface SandboxPermissionDecision {
|
|
|
55
55
|
|
|
56
56
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
57
57
|
|
|
58
|
-
const LANDSTRIP_VERSION = [0, 15,
|
|
58
|
+
const LANDSTRIP_VERSION = [0, 15, 9] as const;
|
|
59
59
|
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
60
60
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
61
61
|
|
|
@@ -102,11 +102,11 @@ function globToRegExp(globPattern: string): RegExp {
|
|
|
102
102
|
let regex = '';
|
|
103
103
|
|
|
104
104
|
for (let i = 0; i < globPattern.length; i++) {
|
|
105
|
-
const char = globPattern
|
|
105
|
+
const char = globPattern.charAt(i);
|
|
106
106
|
if (char === '*') {
|
|
107
|
-
if (globPattern
|
|
107
|
+
if (globPattern.charAt(i + 1) === '*') {
|
|
108
108
|
i++;
|
|
109
|
-
if (globPattern
|
|
109
|
+
if (globPattern.charAt(i + 1) === '/') {
|
|
110
110
|
i++;
|
|
111
111
|
regex += '(?:.*/)?';
|
|
112
112
|
} else {
|
|
@@ -222,19 +222,19 @@ function extractBlockedPath(
|
|
|
222
222
|
let match = output.match(
|
|
223
223
|
/(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
|
|
224
224
|
);
|
|
225
|
-
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
225
|
+
if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
|
|
226
226
|
|
|
227
227
|
// ls/cat/cp: cannot open/access/stat '/path': Permission denied
|
|
228
228
|
match = output.match(
|
|
229
229
|
/^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
|
|
230
230
|
);
|
|
231
|
-
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
231
|
+
if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
|
|
232
232
|
|
|
233
233
|
// Generic: cmd: /absolute/path: Permission denied or Operation not permitted
|
|
234
234
|
match = output.match(
|
|
235
235
|
/^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
|
|
236
236
|
);
|
|
237
|
-
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
237
|
+
if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
|
|
238
238
|
|
|
239
239
|
// Landstrip structured trap format carrying a denied path
|
|
240
240
|
const landstripTraps = parseLandstripTraps(output);
|
|
@@ -390,8 +390,11 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
390
390
|
if (!parsed) return false;
|
|
391
391
|
|
|
392
392
|
for (let i = 0; i < minimum.length; i++) {
|
|
393
|
-
|
|
394
|
-
|
|
393
|
+
const parsedPart = parsed[i];
|
|
394
|
+
const minimumPart = minimum[i];
|
|
395
|
+
if (parsedPart === undefined || minimumPart === undefined) return false;
|
|
396
|
+
if (parsedPart > minimumPart) return true;
|
|
397
|
+
if (parsedPart < minimumPart) return false;
|
|
395
398
|
}
|
|
396
399
|
|
|
397
400
|
return true;
|
|
@@ -399,7 +402,7 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
399
402
|
|
|
400
403
|
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
401
404
|
const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
402
|
-
if (bracketMatch) {
|
|
405
|
+
if (bracketMatch?.[1]) {
|
|
403
406
|
return {
|
|
404
407
|
host: bracketMatch[1],
|
|
405
408
|
port: bracketMatch[2] ? Number(bracketMatch[2]) : defaultPort,
|
|
@@ -497,7 +500,13 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
|
|
|
497
500
|
|
|
498
501
|
async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
|
|
499
502
|
const lines = headerText.split(/\r?\n/);
|
|
500
|
-
const
|
|
503
|
+
const requestLine = lines[0];
|
|
504
|
+
if (!requestLine) {
|
|
505
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const [method, rawTarget, version] = requestLine.split(' ');
|
|
501
510
|
|
|
502
511
|
if (!method || !rawTarget || !version) {
|
|
503
512
|
denyProxyRequest(client, '400 Bad Request');
|
|
@@ -567,10 +576,10 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
|
|
|
567
576
|
const header = buffered.subarray(0, headerEnd).toString('utf-8');
|
|
568
577
|
const rest = buffered.subarray(headerEnd + 4);
|
|
569
578
|
const firstLine = header.split(/\r?\n/, 1)[0];
|
|
570
|
-
const [method, target] = firstLine.split(' ');
|
|
579
|
+
const [method, target] = (firstLine ?? '').split(' ');
|
|
571
580
|
|
|
572
581
|
const task =
|
|
573
|
-
method?.toUpperCase() === 'CONNECT'
|
|
582
|
+
method?.toUpperCase() === 'CONNECT' && target
|
|
574
583
|
? handleConnect(client, target, rest)
|
|
575
584
|
: handleHttp(client, header, rest);
|
|
576
585
|
task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
|
|
@@ -724,13 +733,13 @@ function extractPatchPaths(patchText: string): string[] {
|
|
|
724
733
|
|
|
725
734
|
for (const line of patchText.split(/\r?\n/)) {
|
|
726
735
|
const fileMatch = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/);
|
|
727
|
-
if (fileMatch) {
|
|
736
|
+
if (fileMatch?.[1]) {
|
|
728
737
|
paths.push(fileMatch[1].trim());
|
|
729
738
|
continue;
|
|
730
739
|
}
|
|
731
740
|
|
|
732
741
|
const moveMatch = line.match(/^\*\*\* Move to: (.+)$/);
|
|
733
|
-
if (moveMatch) paths.push(moveMatch[1].trim());
|
|
742
|
+
if (moveMatch?.[1]) paths.push(moveMatch[1].trim());
|
|
734
743
|
}
|
|
735
744
|
|
|
736
745
|
return paths;
|
|
@@ -1025,7 +1034,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1025
1034
|
|
|
1026
1035
|
if (isGeneratedWrappedCommand(args.command as string)) {
|
|
1027
1036
|
const policyMatch = (args.command as string).match(/\s'-p'\s+'([^']+)'/);
|
|
1028
|
-
if (policyMatch && existsSync(policyMatch[1])) {
|
|
1037
|
+
if (policyMatch?.[1] && existsSync(policyMatch[1])) {
|
|
1029
1038
|
if (typeof args.description === 'string')
|
|
1030
1039
|
args.description = landstripDescription(args.description);
|
|
1031
1040
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.3",
|
|
4
4
|
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"check": "tsc --noEmit",
|
|
44
44
|
"test": "node --test test/*.test.mjs",
|
|
45
45
|
"all": "npm run fmt && npm run lint && npm run check && npm test",
|
|
46
|
-
"ci:fmt": "oxfmt --check index.ts tui.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
|
|
46
|
+
"ci:fmt": "oxfmt --check index.ts tui.ts shared.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
|
|
47
47
|
"ci:lint": "npm run lint",
|
|
48
48
|
"ci:check": "npm run check",
|
|
49
49
|
"ci:test": "npm test"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@landstrip/landstrip": "^0.15.
|
|
52
|
+
"@landstrip/landstrip": "^0.15.9"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@opencode-ai/plugin": "^1.17.7",
|
package/shared.ts
CHANGED
|
@@ -238,7 +238,7 @@ export function extractDomainsFromCommand(command: string): string[] {
|
|
|
238
238
|
let match: RegExpExecArray | null;
|
|
239
239
|
|
|
240
240
|
while ((match = urlRegex.exec(command)) !== null) {
|
|
241
|
-
domains.add(match[1]);
|
|
241
|
+
if (match[1]) domains.add(match[1]);
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
return [...domains];
|
|
@@ -358,9 +358,11 @@ export function decodeLandstripTrap(value: unknown): LandstripTrap | null {
|
|
|
358
358
|
case 'filesystem': {
|
|
359
359
|
const { operation, path } = value;
|
|
360
360
|
if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
|
|
361
|
+
const trap: LandstripTrap = { kind: 'filesystem', operation, path, mechanism };
|
|
361
362
|
const state = decodeTrapState(value.state);
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
if (state) trap.state = state;
|
|
364
|
+
if (typeof value.query_id === 'number') trap.queryId = value.query_id;
|
|
365
|
+
return trap;
|
|
364
366
|
}
|
|
365
367
|
case 'network': {
|
|
366
368
|
const { operation, target } = value;
|
package/tui.ts
CHANGED
|
@@ -35,16 +35,17 @@ interface PendingPermission {
|
|
|
35
35
|
|
|
36
36
|
type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
|
|
37
37
|
|
|
38
|
-
type
|
|
38
|
+
type QueryChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
|
|
39
39
|
|
|
40
|
-
// A landstrip
|
|
41
|
-
// dialog stack with permission prompts so the two never
|
|
42
|
-
// common `id`/`kind` shape.
|
|
43
|
-
interface
|
|
44
|
-
kind: '
|
|
40
|
+
// A landstrip filesystem query (read or write) held pending over the fd-3
|
|
41
|
+
// socket. It shares the dialog stack with permission prompts so the two never
|
|
42
|
+
// overlap, hence the common `id`/`kind` shape.
|
|
43
|
+
interface FsQueryEntry {
|
|
44
|
+
kind: 'fs-query';
|
|
45
45
|
id: string;
|
|
46
46
|
socket: NetSocket;
|
|
47
47
|
queryId: number;
|
|
48
|
+
operation: 'read' | 'write';
|
|
48
49
|
path: string;
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -54,7 +55,7 @@ interface PermissionEntry {
|
|
|
54
55
|
permission: PendingPermission;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
type QueueEntry = PermissionEntry |
|
|
58
|
+
type QueueEntry = PermissionEntry | FsQueryEntry;
|
|
58
59
|
|
|
59
60
|
function list(values: string[]): string {
|
|
60
61
|
return values.join(', ') || '(none)';
|
|
@@ -120,10 +121,11 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
120
121
|
// server regenerates the policy from on-disk config each run — so it affects
|
|
121
122
|
// only live socket decisions, not the static policy.
|
|
122
123
|
const sessionAllowedWritePaths = new Set<string>();
|
|
124
|
+
const sessionAllowedReadPaths = new Set<string>();
|
|
123
125
|
|
|
124
|
-
//
|
|
126
|
+
// Filesystem queries still awaiting a response, so cleanup can release held
|
|
125
127
|
// syscalls instead of letting the child hang.
|
|
126
|
-
const liveQueries = new Set<
|
|
128
|
+
const liveQueries = new Set<FsQueryEntry>();
|
|
127
129
|
|
|
128
130
|
function pump(): void {
|
|
129
131
|
if (activeId !== undefined) return;
|
|
@@ -131,7 +133,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
131
133
|
while (next && resolved.has(next.id)) next = queue.shift();
|
|
132
134
|
if (!next) return;
|
|
133
135
|
if (next.kind === 'permission') showPermission(next.permission);
|
|
134
|
-
else
|
|
136
|
+
else showFsQuery(next);
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
function enqueueEntry(entry: QueueEntry): void {
|
|
@@ -267,49 +269,56 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
267
269
|
);
|
|
268
270
|
}
|
|
269
271
|
|
|
270
|
-
function
|
|
272
|
+
function respondFsQuery(socket: NetSocket, queryId: number, action: 'allow' | 'deny'): void {
|
|
271
273
|
if (!socket.destroyed) socket.write(JSON.stringify({ query_id: queryId, action }) + '\n');
|
|
272
274
|
}
|
|
273
275
|
|
|
274
|
-
function
|
|
276
|
+
function resolveFsQuery(entry: FsQueryEntry, choice: QueryChoice): void {
|
|
275
277
|
if (resolved.has(entry.id)) return;
|
|
276
278
|
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
279
|
+
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
277
280
|
|
|
278
281
|
try {
|
|
279
282
|
if (action === 'allow') {
|
|
280
283
|
if (choice === 'session') {
|
|
281
|
-
|
|
284
|
+
const sessionPaths =
|
|
285
|
+
entry.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
|
|
286
|
+
sessionPaths.add(entry.path);
|
|
282
287
|
} else if (choice === 'project' || choice === 'global') {
|
|
283
288
|
const directory = api.state.path.directory || process.cwd();
|
|
284
289
|
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
285
290
|
const update = updateForPermission({
|
|
286
|
-
permission:
|
|
291
|
+
permission: entry.operation,
|
|
287
292
|
metadata: { filepath: entry.path },
|
|
288
293
|
});
|
|
289
294
|
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
290
295
|
}
|
|
291
296
|
}
|
|
292
297
|
|
|
293
|
-
|
|
298
|
+
respondFsQuery(entry.socket, entry.queryId, action);
|
|
294
299
|
api.ui.toast({
|
|
295
300
|
title: 'Sandbox',
|
|
296
|
-
message:
|
|
301
|
+
message:
|
|
302
|
+
action === 'deny' ? `${verb} denied: ${entry.path}` : `${verb} allowed (${choice})`,
|
|
297
303
|
variant: action === 'deny' ? 'warning' : 'success',
|
|
298
304
|
});
|
|
299
305
|
} catch {
|
|
300
306
|
// Persisting failed — still release the held syscall by denying it.
|
|
301
|
-
|
|
307
|
+
respondFsQuery(entry.socket, entry.queryId, 'deny');
|
|
302
308
|
} finally {
|
|
303
309
|
liveQueries.delete(entry);
|
|
304
310
|
finishActive(entry.id);
|
|
305
311
|
}
|
|
306
312
|
}
|
|
307
313
|
|
|
308
|
-
function
|
|
314
|
+
function showFsQuery(entry: FsQueryEntry): void {
|
|
309
315
|
activeId = entry.id;
|
|
316
|
+
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
317
|
+
const noun = entry.operation;
|
|
318
|
+
const listName = entry.operation === 'read' ? 'allowRead' : 'allowWrite';
|
|
310
319
|
|
|
311
320
|
void api.attention.notify({
|
|
312
|
-
title:
|
|
321
|
+
title: `Sandbox ${noun} blocked`,
|
|
313
322
|
message: entry.path,
|
|
314
323
|
sound: { name: 'permission' },
|
|
315
324
|
notification: true,
|
|
@@ -322,48 +331,48 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
322
331
|
|
|
323
332
|
api.ui.dialog.replace(
|
|
324
333
|
() =>
|
|
325
|
-
api.ui.DialogSelect<
|
|
326
|
-
title:
|
|
327
|
-
placeholder:
|
|
334
|
+
api.ui.DialogSelect<QueryChoice>({
|
|
335
|
+
title: `Sandbox ${verb} Blocked`,
|
|
336
|
+
placeholder: `${verb} blocked: ${entry.path}`,
|
|
328
337
|
options: [
|
|
329
338
|
{
|
|
330
339
|
title: 'Allow once',
|
|
331
340
|
value: 'once',
|
|
332
|
-
category:
|
|
333
|
-
description:
|
|
341
|
+
category: `This ${noun}`,
|
|
342
|
+
description: `Permit this ${noun} and continue`,
|
|
334
343
|
},
|
|
335
344
|
{
|
|
336
345
|
title: 'Allow for session',
|
|
337
346
|
value: 'session',
|
|
338
|
-
category:
|
|
339
|
-
description:
|
|
347
|
+
category: `This ${noun}`,
|
|
348
|
+
description: `Permit ${noun}s to this path for the rest of this session`,
|
|
340
349
|
},
|
|
341
350
|
{
|
|
342
351
|
title: 'Allow for project',
|
|
343
352
|
value: 'project',
|
|
344
353
|
category: 'Persist to sandbox.json',
|
|
345
|
-
description:
|
|
354
|
+
description: `Add to .opencode/sandbox.json ${listName} and permit`,
|
|
346
355
|
},
|
|
347
356
|
{
|
|
348
357
|
title: 'Allow globally',
|
|
349
358
|
value: 'global',
|
|
350
359
|
category: 'Persist to sandbox.json',
|
|
351
|
-
description:
|
|
360
|
+
description: `Add to ~/.config/opencode/sandbox.json ${listName} and permit`,
|
|
352
361
|
},
|
|
353
362
|
{
|
|
354
363
|
title: 'Deny',
|
|
355
364
|
value: 'deny',
|
|
356
365
|
category: 'Deny',
|
|
357
|
-
description:
|
|
366
|
+
description: `Block this ${noun}`,
|
|
358
367
|
},
|
|
359
368
|
],
|
|
360
369
|
onSelect: (option) => {
|
|
361
370
|
selectionMade = true;
|
|
362
|
-
|
|
371
|
+
resolveFsQuery(entry, option.value);
|
|
363
372
|
},
|
|
364
373
|
}),
|
|
365
374
|
() => {
|
|
366
|
-
if (!selectionMade)
|
|
375
|
+
if (!selectionMade) resolveFsQuery(entry, 'deny');
|
|
367
376
|
},
|
|
368
377
|
);
|
|
369
378
|
}
|
|
@@ -408,26 +417,23 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
408
417
|
buffer = buffer.slice(newline + 1);
|
|
409
418
|
|
|
410
419
|
for (const trap of parseLandstripTraps(line)) {
|
|
411
|
-
if (
|
|
412
|
-
trap.kind !== 'filesystem' ||
|
|
413
|
-
trap.state !== 'query' ||
|
|
414
|
-
trap.operation !== 'write'
|
|
415
|
-
) {
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
420
|
+
if (trap.kind !== 'filesystem' || trap.state !== 'query') continue;
|
|
418
421
|
if (typeof trap.queryId !== 'number' || seen.has(trap.queryId)) continue;
|
|
419
422
|
seen.add(trap.queryId);
|
|
420
423
|
|
|
421
|
-
|
|
422
|
-
|
|
424
|
+
const sessionPaths =
|
|
425
|
+
trap.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
|
|
426
|
+
if (sessionPaths.has(trap.path)) {
|
|
427
|
+
respondFsQuery(socket, trap.queryId, 'allow');
|
|
423
428
|
continue;
|
|
424
429
|
}
|
|
425
430
|
|
|
426
|
-
const entry:
|
|
427
|
-
kind: '
|
|
428
|
-
id: `landstrip
|
|
431
|
+
const entry: FsQueryEntry = {
|
|
432
|
+
kind: 'fs-query',
|
|
433
|
+
id: `landstrip-${trap.operation}:${socketId}:${trap.queryId}`,
|
|
429
434
|
socket,
|
|
430
435
|
queryId: trap.queryId,
|
|
436
|
+
operation: trap.operation,
|
|
431
437
|
path: trap.path,
|
|
432
438
|
};
|
|
433
439
|
liveQueries.add(entry);
|
|
@@ -572,10 +578,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
572
578
|
unsubscribeAsked();
|
|
573
579
|
unsubscribeReplied();
|
|
574
580
|
|
|
575
|
-
// Deny any still-held
|
|
581
|
+
// Deny any still-held queries so the sandboxed children don't hang, then
|
|
576
582
|
// tear down the socket server and drop the discovery file.
|
|
577
583
|
for (const entry of liveQueries) {
|
|
578
|
-
|
|
584
|
+
respondFsQuery(entry.socket, entry.queryId, 'deny');
|
|
579
585
|
liveQueries.delete(entry);
|
|
580
586
|
}
|
|
581
587
|
for (const socket of sockets) socket.destroy();
|