opencode-landstrip 0.15.0 → 0.15.2
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 +69 -127
- package/package.json +3 -3
- package/shared.ts +190 -3
- package/tui.ts +265 -7
package/index.ts
CHANGED
|
@@ -11,14 +11,18 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
|
11
11
|
import { URL } from 'node:url';
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
type LandstripTrap,
|
|
14
15
|
type SandboxConfig,
|
|
15
16
|
type SandboxFilesystemConfig,
|
|
16
17
|
extractDomainsFromCommand,
|
|
18
|
+
formatLandstripTraps,
|
|
17
19
|
getConfigPaths,
|
|
18
20
|
isRecord,
|
|
19
21
|
landstripBinaryPath,
|
|
20
22
|
loadConfig,
|
|
21
23
|
normalizeOptions,
|
|
24
|
+
parseLandstripTraps,
|
|
25
|
+
readDiscoveryPort,
|
|
22
26
|
} from './shared.js';
|
|
23
27
|
|
|
24
28
|
interface LandstripPolicy {
|
|
@@ -32,13 +36,6 @@ interface LandstripPolicy {
|
|
|
32
36
|
filesystem: SandboxFilesystemConfig;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
type LandstripTrap =
|
|
36
|
-
| { kind: 'filesystem'; operation: 'read' | 'write'; path: string; mechanism: string }
|
|
37
|
-
| { kind: 'network'; operation: string; target: string; mechanism: string }
|
|
38
|
-
| { kind: 'launch'; program: string; message: string }
|
|
39
|
-
| { kind: 'usage'; message: string }
|
|
40
|
-
| { kind: 'internal'; detail: Record<string, string> };
|
|
41
|
-
|
|
42
39
|
interface BashSandboxState {
|
|
43
40
|
originalCommand: string;
|
|
44
41
|
wrappedCommand: string;
|
|
@@ -58,15 +55,10 @@ interface SandboxPermissionDecision {
|
|
|
58
55
|
|
|
59
56
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
60
57
|
|
|
61
|
-
const LANDSTRIP_VERSION = [0, 15,
|
|
58
|
+
const LANDSTRIP_VERSION = [0, 15, 9] as const;
|
|
62
59
|
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
63
|
-
const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
|
|
64
60
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
65
61
|
|
|
66
|
-
function isLandstripOperation(value: unknown): value is 'read' | 'write' {
|
|
67
|
-
return typeof value === 'string' && LANDSTRIP_OPERATIONS.has(value as 'read' | 'write');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
62
|
function expandPath(filePath: string, baseDirectory: string): string {
|
|
71
63
|
const expanded = filePath.replace(/^~(?=$|[/])/, homedir());
|
|
72
64
|
return resolve(isAbsolute(expanded) ? expanded : join(baseDirectory, expanded));
|
|
@@ -110,11 +102,11 @@ function globToRegExp(globPattern: string): RegExp {
|
|
|
110
102
|
let regex = '';
|
|
111
103
|
|
|
112
104
|
for (let i = 0; i < globPattern.length; i++) {
|
|
113
|
-
const char = globPattern
|
|
105
|
+
const char = globPattern.charAt(i);
|
|
114
106
|
if (char === '*') {
|
|
115
|
-
if (globPattern
|
|
107
|
+
if (globPattern.charAt(i + 1) === '*') {
|
|
116
108
|
i++;
|
|
117
|
-
if (globPattern
|
|
109
|
+
if (globPattern.charAt(i + 1) === '/') {
|
|
118
110
|
i++;
|
|
119
111
|
regex += '(?:.*/)?';
|
|
120
112
|
} else {
|
|
@@ -230,23 +222,23 @@ function extractBlockedPath(
|
|
|
230
222
|
let match = output.match(
|
|
231
223
|
/(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
|
|
232
224
|
);
|
|
233
|
-
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
225
|
+
if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
|
|
234
226
|
|
|
235
227
|
// ls/cat/cp: cannot open/access/stat '/path': Permission denied
|
|
236
228
|
match = output.match(
|
|
237
229
|
/^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
|
|
238
230
|
);
|
|
239
|
-
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
231
|
+
if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
|
|
240
232
|
|
|
241
233
|
// Generic: cmd: /absolute/path: Permission denied or Operation not permitted
|
|
242
234
|
match = output.match(
|
|
243
235
|
/^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
|
|
244
236
|
);
|
|
245
|
-
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
237
|
+
if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
|
|
246
238
|
|
|
247
239
|
// Landstrip structured trap format carrying a denied path
|
|
248
|
-
const
|
|
249
|
-
for (const trap of
|
|
240
|
+
const landstripTraps = parseLandstripTraps(output);
|
|
241
|
+
for (const trap of landstripTraps) {
|
|
250
242
|
if (trap.kind === 'filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
|
|
251
243
|
if (trap.kind === 'internal' && trap.detail.file) {
|
|
252
244
|
return normalizeBlockedPath(trap.detail.file, baseDirectory);
|
|
@@ -255,7 +247,7 @@ function extractBlockedPath(
|
|
|
255
247
|
|
|
256
248
|
// If landstrip reported a trap but without a path, try to
|
|
257
249
|
// extract the blocked path from the command itself
|
|
258
|
-
if (
|
|
250
|
+
if (landstripTraps.length > 0 && command) {
|
|
259
251
|
for (const candidate of extractCandidatePaths(command)) {
|
|
260
252
|
const resolved = canonicalizePath(candidate, baseDirectory);
|
|
261
253
|
return resolved;
|
|
@@ -398,105 +390,19 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
398
390
|
if (!parsed) return false;
|
|
399
391
|
|
|
400
392
|
for (let i = 0; i < minimum.length; i++) {
|
|
401
|
-
|
|
402
|
-
|
|
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;
|
|
403
398
|
}
|
|
404
399
|
|
|
405
400
|
return true;
|
|
406
401
|
}
|
|
407
402
|
|
|
408
|
-
function decodeLandstripTrap(value: unknown): LandstripTrap | null {
|
|
409
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
|
410
|
-
const record = value as Record<string, unknown>;
|
|
411
|
-
const mechanism = typeof record.mechanism === 'string' ? record.mechanism : '';
|
|
412
|
-
|
|
413
|
-
switch (record.kind) {
|
|
414
|
-
case 'filesystem': {
|
|
415
|
-
const { operation, path } = record;
|
|
416
|
-
if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
|
|
417
|
-
return { kind: 'filesystem', operation, path, mechanism };
|
|
418
|
-
}
|
|
419
|
-
case 'network': {
|
|
420
|
-
const { operation, target } = record;
|
|
421
|
-
if (typeof operation !== 'string' || typeof target !== 'string') return null;
|
|
422
|
-
return { kind: 'network', operation, target, mechanism };
|
|
423
|
-
}
|
|
424
|
-
case 'launch': {
|
|
425
|
-
const { program, message } = record;
|
|
426
|
-
if (typeof program !== 'string') return null;
|
|
427
|
-
return { kind: 'launch', program, message: typeof message === 'string' ? message : '' };
|
|
428
|
-
}
|
|
429
|
-
case 'usage': {
|
|
430
|
-
const { message } = record;
|
|
431
|
-
if (typeof message !== 'string') return null;
|
|
432
|
-
return { kind: 'usage', message };
|
|
433
|
-
}
|
|
434
|
-
case 'internal': {
|
|
435
|
-
const detail: Record<string, string> = {};
|
|
436
|
-
const payload = record.detail;
|
|
437
|
-
if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) {
|
|
438
|
-
for (const [key, val] of Object.entries(payload as Record<string, unknown>)) {
|
|
439
|
-
detail[key] = typeof val === 'string' ? val : JSON.stringify(val);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
return { kind: 'internal', detail };
|
|
443
|
-
}
|
|
444
|
-
default:
|
|
445
|
-
return null;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function parseLandstripErrors(output: string): LandstripTrap[] {
|
|
450
|
-
const traps: LandstripTrap[] = [];
|
|
451
|
-
|
|
452
|
-
for (const line of output.split('\n')) {
|
|
453
|
-
const trimmed = line.trim();
|
|
454
|
-
if (trimmed.length === 0 || trimmed[0] !== '{') continue;
|
|
455
|
-
|
|
456
|
-
let parsed: unknown;
|
|
457
|
-
try {
|
|
458
|
-
parsed = JSON.parse(trimmed);
|
|
459
|
-
} catch {
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const trap = decodeLandstripTrap(parsed);
|
|
464
|
-
if (trap) traps.push(trap);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return traps;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function formatLandstripTrap(trap: LandstripTrap): string {
|
|
471
|
-
switch (trap.kind) {
|
|
472
|
-
case 'filesystem':
|
|
473
|
-
return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
|
|
474
|
-
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
475
|
-
}`;
|
|
476
|
-
case 'network':
|
|
477
|
-
return `landstrip: network ${trap.operation} denied (${trap.target})${
|
|
478
|
-
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
479
|
-
}`;
|
|
480
|
-
case 'launch':
|
|
481
|
-
return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
|
|
482
|
-
case 'usage':
|
|
483
|
-
return `landstrip: usage error: ${trap.message}`;
|
|
484
|
-
case 'internal': {
|
|
485
|
-
const detail = Object.entries(trap.detail)
|
|
486
|
-
.map(([key, val]) => `${key}: ${val}`)
|
|
487
|
-
.join(', ');
|
|
488
|
-
return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function formatLandstripErrors(traps: LandstripTrap[]): string {
|
|
494
|
-
return traps.map(formatLandstripTrap).join('\n');
|
|
495
|
-
}
|
|
496
|
-
|
|
497
403
|
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
498
404
|
const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
499
|
-
if (bracketMatch) {
|
|
405
|
+
if (bracketMatch?.[1]) {
|
|
500
406
|
return {
|
|
501
407
|
host: bracketMatch[1],
|
|
502
408
|
port: bracketMatch[2] ? Number(bracketMatch[2]) : defaultPort,
|
|
@@ -594,7 +500,13 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
|
|
|
594
500
|
|
|
595
501
|
async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
|
|
596
502
|
const lines = headerText.split(/\r?\n/);
|
|
597
|
-
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(' ');
|
|
598
510
|
|
|
599
511
|
if (!method || !rawTarget || !version) {
|
|
600
512
|
denyProxyRequest(client, '400 Bad Request');
|
|
@@ -664,10 +576,10 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
|
|
|
664
576
|
const header = buffered.subarray(0, headerEnd).toString('utf-8');
|
|
665
577
|
const rest = buffered.subarray(headerEnd + 4);
|
|
666
578
|
const firstLine = header.split(/\r?\n/, 1)[0];
|
|
667
|
-
const [method, target] = firstLine.split(' ');
|
|
579
|
+
const [method, target] = (firstLine ?? '').split(' ');
|
|
668
580
|
|
|
669
581
|
const task =
|
|
670
|
-
method?.toUpperCase() === 'CONNECT'
|
|
582
|
+
method?.toUpperCase() === 'CONNECT' && target
|
|
671
583
|
? handleConnect(client, target, rest)
|
|
672
584
|
: handleHttp(client, header, rest);
|
|
673
585
|
task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
|
|
@@ -727,15 +639,37 @@ function shellArgs(shell: string, command: string): string[] {
|
|
|
727
639
|
return [shell, '-lc', command];
|
|
728
640
|
}
|
|
729
641
|
|
|
730
|
-
|
|
731
|
-
|
|
642
|
+
// The query-response port is published by the TUI plugin and is Linux-only (the
|
|
643
|
+
// socket protocol exists only in landstrip's seccomp broker).
|
|
644
|
+
function socketQueryPort(baseDirectory: string): number | null {
|
|
645
|
+
if (process.platform !== 'linux') return null;
|
|
646
|
+
return readDiscoveryPort(baseDirectory);
|
|
647
|
+
}
|
|
732
648
|
|
|
733
|
-
|
|
649
|
+
function buildWrappedCommand(
|
|
650
|
+
policyPath: string,
|
|
651
|
+
shell: string,
|
|
652
|
+
command: string,
|
|
653
|
+
trapPort: number | null,
|
|
654
|
+
): string {
|
|
655
|
+
const baseArgs = ['-p', policyPath, ...shellArgs(shell, command)];
|
|
656
|
+
const plain = [landstripBinaryPath(), ...baseArgs].map(shellQuote).join(' ');
|
|
657
|
+
if (trapPort === null) return plain;
|
|
658
|
+
|
|
659
|
+
// Connect fd 3 to the TUI's query-response socket BEFORE landstrip applies the
|
|
660
|
+
// sandbox, so a denied write can be approved live instead of forcing a re-run.
|
|
661
|
+
// The `&&`/`||` guard falls back to the plain (no --trap-fd) invocation when the
|
|
662
|
+
// redirect fails — a dead port, a non-bash outer shell without /dev/tcp, or an
|
|
663
|
+
// outer `set -e` — so a failed connect never hands the broker a dead fd 3.
|
|
664
|
+
const trapped = [landstripBinaryPath(), '--trap-fd', '3', ...baseArgs].map(shellQuote).join(' ');
|
|
665
|
+
return `{ exec 3<>/dev/tcp/127.0.0.1/${trapPort} ; } 2>/dev/null && ${trapped} || ${plain}`;
|
|
734
666
|
}
|
|
735
667
|
|
|
736
668
|
function isGeneratedWrappedCommand(command: string): boolean {
|
|
737
669
|
return (
|
|
738
|
-
|
|
670
|
+
// `.includes` rather than `.startsWith`: the query-response form prefixes a
|
|
671
|
+
// `{ exec 3<>/dev/tcp/...; } && ` redirect before the landstrip invocation.
|
|
672
|
+
command.includes(`${shellQuote(landstripBinaryPath())} `) &&
|
|
739
673
|
command.includes(` ${shellQuote('-p')} `) &&
|
|
740
674
|
command.includes('opencode-landstrip-')
|
|
741
675
|
);
|
|
@@ -779,7 +713,10 @@ function extractOriginalCommand(wrappedCommand: string): string | null {
|
|
|
779
713
|
const flagIdx = pIdx + 3;
|
|
780
714
|
const flag = args[flagIdx];
|
|
781
715
|
if (flag !== '-lc' && flag !== '-c') return null;
|
|
782
|
-
|
|
716
|
+
// The query-response form appends `|| <plain invocation>`; stop at that
|
|
717
|
+
// separator so the fallback branch is not folded into the recovered command.
|
|
718
|
+
const end = args.indexOf('||', flagIdx + 1);
|
|
719
|
+
return (end === -1 ? args.slice(flagIdx + 1) : args.slice(flagIdx + 1, end)).join(' ');
|
|
783
720
|
}
|
|
784
721
|
|
|
785
722
|
function getToolPath(args: Record<string, unknown>): string | undefined {
|
|
@@ -796,13 +733,13 @@ function extractPatchPaths(patchText: string): string[] {
|
|
|
796
733
|
|
|
797
734
|
for (const line of patchText.split(/\r?\n/)) {
|
|
798
735
|
const fileMatch = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/);
|
|
799
|
-
if (fileMatch) {
|
|
736
|
+
if (fileMatch?.[1]) {
|
|
800
737
|
paths.push(fileMatch[1].trim());
|
|
801
738
|
continue;
|
|
802
739
|
}
|
|
803
740
|
|
|
804
741
|
const moveMatch = line.match(/^\*\*\* Move to: (.+)$/);
|
|
805
|
-
if (moveMatch) paths.push(moveMatch[1].trim());
|
|
742
|
+
if (moveMatch?.[1]) paths.push(moveMatch[1].trim());
|
|
806
743
|
}
|
|
807
744
|
|
|
808
745
|
return paths;
|
|
@@ -1097,7 +1034,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1097
1034
|
|
|
1098
1035
|
if (isGeneratedWrappedCommand(args.command as string)) {
|
|
1099
1036
|
const policyMatch = (args.command as string).match(/\s'-p'\s+'([^']+)'/);
|
|
1100
|
-
if (policyMatch && existsSync(policyMatch[1])) {
|
|
1037
|
+
if (policyMatch?.[1] && existsSync(policyMatch[1])) {
|
|
1101
1038
|
if (typeof args.description === 'string')
|
|
1102
1039
|
args.description = landstripDescription(args.description);
|
|
1103
1040
|
return;
|
|
@@ -1151,6 +1088,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1151
1088
|
policy.path,
|
|
1152
1089
|
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
1153
1090
|
originalCommand,
|
|
1091
|
+
socketQueryPort(directory),
|
|
1154
1092
|
);
|
|
1155
1093
|
|
|
1156
1094
|
activeBash.set(callID, {
|
|
@@ -1316,9 +1254,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1316
1254
|
}
|
|
1317
1255
|
|
|
1318
1256
|
const outputText = output?.output ?? '';
|
|
1319
|
-
|
|
1257
|
+
// Query traps were already resolved interactively over the socket by the
|
|
1258
|
+
// TUI plugin; only terminal (info) traps belong in the after-the-fact toast.
|
|
1259
|
+
const errors = parseLandstripTraps(outputText).filter(
|
|
1260
|
+
(trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
|
|
1261
|
+
);
|
|
1320
1262
|
if (errors.length > 0) {
|
|
1321
|
-
const message =
|
|
1263
|
+
const message = formatLandstripTraps(errors);
|
|
1322
1264
|
await client.tui
|
|
1323
1265
|
?.showToast?.({
|
|
1324
1266
|
body: { title: 'opencode-landstrip', message, variant: 'error' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
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
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { binaryPath } from '@landstrip/landstrip';
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir, tmpdir } from 'node:os';
|
|
8
9
|
import { dirname, join } from 'node:path';
|
|
9
10
|
|
|
10
11
|
export interface SandboxFilesystemConfig {
|
|
@@ -237,7 +238,7 @@ export function extractDomainsFromCommand(command: string): string[] {
|
|
|
237
238
|
let match: RegExpExecArray | null;
|
|
238
239
|
|
|
239
240
|
while ((match = urlRegex.exec(command)) !== null) {
|
|
240
|
-
domains.add(match[1]);
|
|
241
|
+
if (match[1]) domains.add(match[1]);
|
|
241
242
|
}
|
|
242
243
|
|
|
243
244
|
return [...domains];
|
|
@@ -316,3 +317,189 @@ export function updateForPermission(
|
|
|
316
317
|
|
|
317
318
|
return null;
|
|
318
319
|
}
|
|
320
|
+
|
|
321
|
+
// Landstrip emits one JSON trap per line on its trap fd/file. The `state` field
|
|
322
|
+
// (landstrip >= 0.15.4) distinguishes a terminal `info` trap from a `query`
|
|
323
|
+
// trap that holds a syscall pending until the host answers with `queryId`. It
|
|
324
|
+
// is absent on the static-profile platforms (macOS/Windows), so both fields are
|
|
325
|
+
// optional. Parsing lives here so the server hook and the TUI socket handler
|
|
326
|
+
// decode identically.
|
|
327
|
+
export type LandstripTrapState = 'query' | 'info';
|
|
328
|
+
|
|
329
|
+
export type LandstripTrap =
|
|
330
|
+
| {
|
|
331
|
+
kind: 'filesystem';
|
|
332
|
+
operation: 'read' | 'write';
|
|
333
|
+
path: string;
|
|
334
|
+
mechanism: string;
|
|
335
|
+
state?: LandstripTrapState;
|
|
336
|
+
queryId?: number;
|
|
337
|
+
}
|
|
338
|
+
| { kind: 'network'; operation: string; target: string; mechanism: string }
|
|
339
|
+
| { kind: 'launch'; program: string; message: string }
|
|
340
|
+
| { kind: 'usage'; message: string }
|
|
341
|
+
| { kind: 'internal'; detail: Record<string, string> };
|
|
342
|
+
|
|
343
|
+
const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
|
|
344
|
+
|
|
345
|
+
function isLandstripOperation(value: unknown): value is 'read' | 'write' {
|
|
346
|
+
return typeof value === 'string' && LANDSTRIP_OPERATIONS.has(value as 'read' | 'write');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function decodeTrapState(value: unknown): LandstripTrapState | undefined {
|
|
350
|
+
return value === 'query' || value === 'info' ? value : undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function decodeLandstripTrap(value: unknown): LandstripTrap | null {
|
|
354
|
+
if (!isRecord(value)) return null;
|
|
355
|
+
const mechanism = typeof value.mechanism === 'string' ? value.mechanism : '';
|
|
356
|
+
|
|
357
|
+
switch (value.kind) {
|
|
358
|
+
case 'filesystem': {
|
|
359
|
+
const { operation, path } = value;
|
|
360
|
+
if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
|
|
361
|
+
const trap: LandstripTrap = { kind: 'filesystem', operation, path, mechanism };
|
|
362
|
+
const state = decodeTrapState(value.state);
|
|
363
|
+
if (state) trap.state = state;
|
|
364
|
+
if (typeof value.query_id === 'number') trap.queryId = value.query_id;
|
|
365
|
+
return trap;
|
|
366
|
+
}
|
|
367
|
+
case 'network': {
|
|
368
|
+
const { operation, target } = value;
|
|
369
|
+
if (typeof operation !== 'string' || typeof target !== 'string') return null;
|
|
370
|
+
return { kind: 'network', operation, target, mechanism };
|
|
371
|
+
}
|
|
372
|
+
case 'launch': {
|
|
373
|
+
const { program, message } = value;
|
|
374
|
+
if (typeof program !== 'string') return null;
|
|
375
|
+
return { kind: 'launch', program, message: typeof message === 'string' ? message : '' };
|
|
376
|
+
}
|
|
377
|
+
case 'usage': {
|
|
378
|
+
const { message } = value;
|
|
379
|
+
if (typeof message !== 'string') return null;
|
|
380
|
+
return { kind: 'usage', message };
|
|
381
|
+
}
|
|
382
|
+
case 'internal': {
|
|
383
|
+
const detail: Record<string, string> = {};
|
|
384
|
+
const payload = value.detail;
|
|
385
|
+
if (isRecord(payload)) {
|
|
386
|
+
for (const [key, val] of Object.entries(payload)) {
|
|
387
|
+
detail[key] = typeof val === 'string' ? val : JSON.stringify(val);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { kind: 'internal', detail };
|
|
391
|
+
}
|
|
392
|
+
default:
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function parseLandstripTraps(output: string): LandstripTrap[] {
|
|
398
|
+
const traps: LandstripTrap[] = [];
|
|
399
|
+
|
|
400
|
+
for (const line of output.split('\n')) {
|
|
401
|
+
const trimmed = line.trim();
|
|
402
|
+
if (trimmed.length === 0 || trimmed[0] !== '{') continue;
|
|
403
|
+
|
|
404
|
+
let parsed: unknown;
|
|
405
|
+
try {
|
|
406
|
+
parsed = JSON.parse(trimmed);
|
|
407
|
+
} catch {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const trap = decodeLandstripTrap(parsed);
|
|
412
|
+
if (trap) traps.push(trap);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return traps;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function formatLandstripTrap(trap: LandstripTrap): string {
|
|
419
|
+
switch (trap.kind) {
|
|
420
|
+
case 'filesystem':
|
|
421
|
+
return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
|
|
422
|
+
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
423
|
+
}`;
|
|
424
|
+
case 'network':
|
|
425
|
+
return `landstrip: network ${trap.operation} denied (${trap.target})${
|
|
426
|
+
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
427
|
+
}`;
|
|
428
|
+
case 'launch':
|
|
429
|
+
return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
|
|
430
|
+
case 'usage':
|
|
431
|
+
return `landstrip: usage error: ${trap.message}`;
|
|
432
|
+
case 'internal': {
|
|
433
|
+
const detail = Object.entries(trap.detail)
|
|
434
|
+
.map(([key, val]) => `${key}: ${val}`)
|
|
435
|
+
.join(', ');
|
|
436
|
+
return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function formatLandstripTraps(traps: LandstripTrap[]): string {
|
|
442
|
+
return traps.map(formatLandstripTrap).join('\n');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The TUI plugin runs the query-response socket server and publishes its port
|
|
446
|
+
// to a per-directory discovery file; the server plugin reads it to inject the
|
|
447
|
+
// fd-3 redirect. Namespacing by a hash of the realpath keeps concurrent
|
|
448
|
+
// opencode instances in different projects from colliding.
|
|
449
|
+
function discoveryDir(): string {
|
|
450
|
+
const base = process.env.XDG_RUNTIME_DIR || tmpdir();
|
|
451
|
+
return join(base, 'opencode-landstrip');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function discoveryFilePath(baseDirectory: string): string {
|
|
455
|
+
let key = baseDirectory;
|
|
456
|
+
try {
|
|
457
|
+
key = realpathSync.native(baseDirectory);
|
|
458
|
+
} catch {
|
|
459
|
+
// Directory not resolvable — hash the raw path instead.
|
|
460
|
+
}
|
|
461
|
+
const hash = createHash('sha256').update(key).digest('hex').slice(0, 16);
|
|
462
|
+
return join(discoveryDir(), `port-${hash}.json`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function writeDiscoveryPort(baseDirectory: string, port: number): void {
|
|
466
|
+
const dir = discoveryDir();
|
|
467
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
468
|
+
writeFileSync(
|
|
469
|
+
discoveryFilePath(baseDirectory),
|
|
470
|
+
JSON.stringify({ port, pid: process.pid, ts: Date.now() }) + '\n',
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function removeDiscoveryFile(baseDirectory: string): void {
|
|
475
|
+
rmSync(discoveryFilePath(baseDirectory), { force: true });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Returns the live query-response port, or null when no fresh server is
|
|
479
|
+
// listening. A recorded writer pid that no longer exists marks the file stale.
|
|
480
|
+
export function readDiscoveryPort(baseDirectory: string): number | null {
|
|
481
|
+
const path = discoveryFilePath(baseDirectory);
|
|
482
|
+
if (!existsSync(path)) return null;
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const data: unknown = JSON.parse(readFileSync(path, 'utf-8'));
|
|
486
|
+
if (!isRecord(data)) return null;
|
|
487
|
+
|
|
488
|
+
const port = typeof data.port === 'number' ? data.port : NaN;
|
|
489
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
490
|
+
|
|
491
|
+
if (typeof data.pid === 'number') {
|
|
492
|
+
try {
|
|
493
|
+
process.kill(data.pid, 0);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
// ESRCH: the writer is gone, so the file is stale. EPERM: alive but
|
|
496
|
+
// owned by another user — still a live listener, so accept it.
|
|
497
|
+
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return port;
|
|
502
|
+
} catch {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
}
|
package/tui.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
|
|
5
5
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
+
import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
type SandboxConfigOverrides,
|
|
@@ -11,10 +12,13 @@ import {
|
|
|
11
12
|
landstripBinaryPath,
|
|
12
13
|
loadConfig,
|
|
13
14
|
normalizeOptions,
|
|
15
|
+
parseLandstripTraps,
|
|
14
16
|
permissionLabel,
|
|
15
17
|
permissionResource,
|
|
18
|
+
removeDiscoveryFile,
|
|
16
19
|
updateForPermission,
|
|
17
20
|
writeConfigFile,
|
|
21
|
+
writeDiscoveryPort,
|
|
18
22
|
} from './shared.js';
|
|
19
23
|
|
|
20
24
|
// The shape shared by the `permission.asked` event payload and the entries
|
|
@@ -31,6 +35,28 @@ interface PendingPermission {
|
|
|
31
35
|
|
|
32
36
|
type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
|
|
33
37
|
|
|
38
|
+
type QueryChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
|
|
39
|
+
|
|
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
|
+
id: string;
|
|
46
|
+
socket: NetSocket;
|
|
47
|
+
queryId: number;
|
|
48
|
+
operation: 'read' | 'write';
|
|
49
|
+
path: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PermissionEntry {
|
|
53
|
+
kind: 'permission';
|
|
54
|
+
id: string;
|
|
55
|
+
permission: PendingPermission;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type QueueEntry = PermissionEntry | FsQueryEntry;
|
|
59
|
+
|
|
34
60
|
function list(values: string[]): string {
|
|
35
61
|
return values.join(', ') || '(none)';
|
|
36
62
|
}
|
|
@@ -84,26 +110,45 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
84
110
|
// Permission requests can arrive twice (the live event and a reconnect replay
|
|
85
111
|
// of `api.state`), so `resolved` tracks ids we have already answered and
|
|
86
112
|
// `activeId` guards against stacking a second sandbox dialog on the first.
|
|
113
|
+
// Write queries share the same queue so a held-write prompt never stacks on a
|
|
114
|
+
// permission prompt.
|
|
87
115
|
const resolved = new Set<string>();
|
|
88
|
-
const queue:
|
|
116
|
+
const queue: QueueEntry[] = [];
|
|
89
117
|
let activeId: string | undefined;
|
|
90
118
|
|
|
119
|
+
// Paths the user approved "for session": later queries for the same path are
|
|
120
|
+
// auto-allowed without a dialog. This lives only in the TUI process — the
|
|
121
|
+
// server regenerates the policy from on-disk config each run — so it affects
|
|
122
|
+
// only live socket decisions, not the static policy.
|
|
123
|
+
const sessionAllowedWritePaths = new Set<string>();
|
|
124
|
+
const sessionAllowedReadPaths = new Set<string>();
|
|
125
|
+
|
|
126
|
+
// Filesystem queries still awaiting a response, so cleanup can release held
|
|
127
|
+
// syscalls instead of letting the child hang.
|
|
128
|
+
const liveQueries = new Set<FsQueryEntry>();
|
|
129
|
+
|
|
91
130
|
function pump(): void {
|
|
92
131
|
if (activeId !== undefined) return;
|
|
93
132
|
let next = queue.shift();
|
|
94
133
|
while (next && resolved.has(next.id)) next = queue.shift();
|
|
95
134
|
if (!next) return;
|
|
96
|
-
showPermission(next);
|
|
135
|
+
if (next.kind === 'permission') showPermission(next.permission);
|
|
136
|
+
else showFsQuery(next);
|
|
97
137
|
}
|
|
98
138
|
|
|
99
|
-
function
|
|
100
|
-
if (!
|
|
101
|
-
if (activeId ===
|
|
102
|
-
if (queue.some((item) => item.id ===
|
|
103
|
-
queue.push(
|
|
139
|
+
function enqueueEntry(entry: QueueEntry): void {
|
|
140
|
+
if (!entry.id || resolved.has(entry.id)) return;
|
|
141
|
+
if (activeId === entry.id) return;
|
|
142
|
+
if (queue.some((item) => item.id === entry.id)) return;
|
|
143
|
+
queue.push(entry);
|
|
104
144
|
pump();
|
|
105
145
|
}
|
|
106
146
|
|
|
147
|
+
function enqueue(permission: PendingPermission): void {
|
|
148
|
+
if (!permission.id) return;
|
|
149
|
+
enqueueEntry({ kind: 'permission', id: permission.id, permission });
|
|
150
|
+
}
|
|
151
|
+
|
|
107
152
|
// Safety net for missed/late events and reconnects: fold whatever the host
|
|
108
153
|
// still considers pending for this session back into the queue.
|
|
109
154
|
function reconcile(sessionID: string): void {
|
|
@@ -224,6 +269,114 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
224
269
|
);
|
|
225
270
|
}
|
|
226
271
|
|
|
272
|
+
function respondFsQuery(socket: NetSocket, queryId: number, action: 'allow' | 'deny'): void {
|
|
273
|
+
if (!socket.destroyed) socket.write(JSON.stringify({ query_id: queryId, action }) + '\n');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function resolveFsQuery(entry: FsQueryEntry, choice: QueryChoice): void {
|
|
277
|
+
if (resolved.has(entry.id)) return;
|
|
278
|
+
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
279
|
+
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
if (action === 'allow') {
|
|
283
|
+
if (choice === 'session') {
|
|
284
|
+
const sessionPaths =
|
|
285
|
+
entry.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
|
|
286
|
+
sessionPaths.add(entry.path);
|
|
287
|
+
} else if (choice === 'project' || choice === 'global') {
|
|
288
|
+
const directory = api.state.path.directory || process.cwd();
|
|
289
|
+
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
290
|
+
const update = updateForPermission({
|
|
291
|
+
permission: entry.operation,
|
|
292
|
+
metadata: { filepath: entry.path },
|
|
293
|
+
});
|
|
294
|
+
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
respondFsQuery(entry.socket, entry.queryId, action);
|
|
299
|
+
api.ui.toast({
|
|
300
|
+
title: 'Sandbox',
|
|
301
|
+
message:
|
|
302
|
+
action === 'deny' ? `${verb} denied: ${entry.path}` : `${verb} allowed (${choice})`,
|
|
303
|
+
variant: action === 'deny' ? 'warning' : 'success',
|
|
304
|
+
});
|
|
305
|
+
} catch {
|
|
306
|
+
// Persisting failed — still release the held syscall by denying it.
|
|
307
|
+
respondFsQuery(entry.socket, entry.queryId, 'deny');
|
|
308
|
+
} finally {
|
|
309
|
+
liveQueries.delete(entry);
|
|
310
|
+
finishActive(entry.id);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function showFsQuery(entry: FsQueryEntry): void {
|
|
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';
|
|
319
|
+
|
|
320
|
+
void api.attention.notify({
|
|
321
|
+
title: `Sandbox ${noun} blocked`,
|
|
322
|
+
message: entry.path,
|
|
323
|
+
sound: { name: 'permission' },
|
|
324
|
+
notification: true,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// A selection pops the dialog (firing `onClose`); track it so the esc-path
|
|
328
|
+
// deny does not override the user's choice. A held syscall must always be
|
|
329
|
+
// answered, so esc/dismiss denies rather than leaving it unresolved.
|
|
330
|
+
let selectionMade = false;
|
|
331
|
+
|
|
332
|
+
api.ui.dialog.replace(
|
|
333
|
+
() =>
|
|
334
|
+
api.ui.DialogSelect<QueryChoice>({
|
|
335
|
+
title: `Sandbox ${verb} Blocked`,
|
|
336
|
+
placeholder: `${verb} blocked: ${entry.path}`,
|
|
337
|
+
options: [
|
|
338
|
+
{
|
|
339
|
+
title: 'Allow once',
|
|
340
|
+
value: 'once',
|
|
341
|
+
category: `This ${noun}`,
|
|
342
|
+
description: `Permit this ${noun} and continue`,
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
title: 'Allow for session',
|
|
346
|
+
value: 'session',
|
|
347
|
+
category: `This ${noun}`,
|
|
348
|
+
description: `Permit ${noun}s to this path for the rest of this session`,
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
title: 'Allow for project',
|
|
352
|
+
value: 'project',
|
|
353
|
+
category: 'Persist to sandbox.json',
|
|
354
|
+
description: `Add to .opencode/sandbox.json ${listName} and permit`,
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
title: 'Allow globally',
|
|
358
|
+
value: 'global',
|
|
359
|
+
category: 'Persist to sandbox.json',
|
|
360
|
+
description: `Add to ~/.config/opencode/sandbox.json ${listName} and permit`,
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
title: 'Deny',
|
|
364
|
+
value: 'deny',
|
|
365
|
+
category: 'Deny',
|
|
366
|
+
description: `Block this ${noun}`,
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
onSelect: (option) => {
|
|
370
|
+
selectionMade = true;
|
|
371
|
+
resolveFsQuery(entry, option.value);
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
() => {
|
|
375
|
+
if (!selectionMade) resolveFsQuery(entry, 'deny');
|
|
376
|
+
},
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
227
380
|
const unsubscribeAsked = api.event.on('permission.asked', (event) => {
|
|
228
381
|
const pending = event.properties as PendingPermission;
|
|
229
382
|
enqueue(pending);
|
|
@@ -234,6 +387,95 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
234
387
|
finishActive(event.properties.requestID);
|
|
235
388
|
});
|
|
236
389
|
|
|
390
|
+
// Query-response socket server (Linux-only — landstrip's socket protocol lives
|
|
391
|
+
// in the seccomp broker). The server plugin connects each sandboxed run's
|
|
392
|
+
// fd 3 here via a /dev/tcp redirect and we answer held writes interactively.
|
|
393
|
+
const sockets = new Set<NetSocket>();
|
|
394
|
+
let socketServer: ReturnType<typeof createServer> | undefined;
|
|
395
|
+
|
|
396
|
+
if (process.platform === 'linux') {
|
|
397
|
+
const baseDirectory = api.state.path.directory || process.cwd();
|
|
398
|
+
let socketSeq = 0;
|
|
399
|
+
|
|
400
|
+
socketServer = createServer((socket) => {
|
|
401
|
+
sockets.add(socket);
|
|
402
|
+
socket.setEncoding('utf8');
|
|
403
|
+
const socketId = ++socketSeq;
|
|
404
|
+
const seen = new Set<number>();
|
|
405
|
+
let buffer = '';
|
|
406
|
+
|
|
407
|
+
socket.on('data', (chunk: string | Buffer) => {
|
|
408
|
+
buffer += chunk.toString();
|
|
409
|
+
if (buffer.length > 1024 * 1024) {
|
|
410
|
+
socket.destroy();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let newline: number;
|
|
415
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
416
|
+
const line = buffer.slice(0, newline);
|
|
417
|
+
buffer = buffer.slice(newline + 1);
|
|
418
|
+
|
|
419
|
+
for (const trap of parseLandstripTraps(line)) {
|
|
420
|
+
if (trap.kind !== 'filesystem' || trap.state !== 'query') continue;
|
|
421
|
+
if (typeof trap.queryId !== 'number' || seen.has(trap.queryId)) continue;
|
|
422
|
+
seen.add(trap.queryId);
|
|
423
|
+
|
|
424
|
+
const sessionPaths =
|
|
425
|
+
trap.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
|
|
426
|
+
if (sessionPaths.has(trap.path)) {
|
|
427
|
+
respondFsQuery(socket, trap.queryId, 'allow');
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const entry: FsQueryEntry = {
|
|
432
|
+
kind: 'fs-query',
|
|
433
|
+
id: `landstrip-${trap.operation}:${socketId}:${trap.queryId}`,
|
|
434
|
+
socket,
|
|
435
|
+
queryId: trap.queryId,
|
|
436
|
+
operation: trap.operation,
|
|
437
|
+
path: trap.path,
|
|
438
|
+
};
|
|
439
|
+
liveQueries.add(entry);
|
|
440
|
+
enqueueEntry(entry);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const cleanup = () => {
|
|
446
|
+
sockets.delete(socket);
|
|
447
|
+
// The child is gone; drop our holds for this socket so the queue moves on.
|
|
448
|
+
// Deleting the current entry mid-iteration is well-defined for a Set.
|
|
449
|
+
for (const entry of liveQueries) {
|
|
450
|
+
if (entry.socket !== socket) continue;
|
|
451
|
+
liveQueries.delete(entry);
|
|
452
|
+
finishActive(entry.id);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
socket.on('error', cleanup);
|
|
456
|
+
socket.on('close', cleanup);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
socketServer.on('error', () => {
|
|
460
|
+
try {
|
|
461
|
+
removeDiscoveryFile(baseDirectory);
|
|
462
|
+
} catch {
|
|
463
|
+
// best effort
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
socketServer.listen(0, '127.0.0.1', () => {
|
|
468
|
+
const address = socketServer?.address() as AddressInfo | null;
|
|
469
|
+
if (address && typeof address === 'object') {
|
|
470
|
+
try {
|
|
471
|
+
writeDiscoveryPort(baseDirectory, address.port);
|
|
472
|
+
} catch {
|
|
473
|
+
// best effort — falls back to the server's reset model
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
237
479
|
const showSandbox = () => {
|
|
238
480
|
const directory = api.state.path.directory || process.cwd();
|
|
239
481
|
const message = sandboxSummary(directory, optionOverrides);
|
|
@@ -335,6 +577,22 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
335
577
|
api.lifecycle.onDispose(() => {
|
|
336
578
|
unsubscribeAsked();
|
|
337
579
|
unsubscribeReplied();
|
|
580
|
+
|
|
581
|
+
// Deny any still-held queries so the sandboxed children don't hang, then
|
|
582
|
+
// tear down the socket server and drop the discovery file.
|
|
583
|
+
for (const entry of liveQueries) {
|
|
584
|
+
respondFsQuery(entry.socket, entry.queryId, 'deny');
|
|
585
|
+
liveQueries.delete(entry);
|
|
586
|
+
}
|
|
587
|
+
for (const socket of sockets) socket.destroy();
|
|
588
|
+
if (socketServer) {
|
|
589
|
+
socketServer.close();
|
|
590
|
+
try {
|
|
591
|
+
removeDiscoveryFile(api.state.path.directory || process.cwd());
|
|
592
|
+
} catch {
|
|
593
|
+
// best effort
|
|
594
|
+
}
|
|
595
|
+
}
|
|
338
596
|
});
|
|
339
597
|
};
|
|
340
598
|
|