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.
Files changed (4) hide show
  1. package/index.ts +69 -127
  2. package/package.json +3 -3
  3. package/shared.ts +190 -3
  4. 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, 1] as const;
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[i];
105
+ const char = globPattern.charAt(i);
114
106
  if (char === '*') {
115
- if (globPattern[i + 1] === '*') {
107
+ if (globPattern.charAt(i + 1) === '*') {
116
108
  i++;
117
- if (globPattern[i + 1] === '/') {
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 landstripErrors = parseLandstripErrors(output);
249
- for (const trap of landstripErrors) {
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 (landstripErrors.length > 0 && command) {
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
- if (parsed[i] > minimum[i]) return true;
402
- if (parsed[i] < minimum[i]) return false;
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 [method, rawTarget, version] = lines[0].split(' ');
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
- function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
731
- const args = ['-p', policyPath, ...shellArgs(shell, command)];
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
- return [landstripBinaryPath(), ...args].map(shellQuote).join(' ');
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
- command.startsWith(`${shellQuote(landstripBinaryPath())} `) &&
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
- return args.slice(flagIdx + 1).join(' ');
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
- const errors = parseLandstripErrors(outputText);
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 = formatLandstripErrors(errors);
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.0",
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.1"
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 { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
7
- import { homedir } from 'node:os';
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: PendingPermission[] = [];
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 enqueue(permission: PendingPermission): void {
100
- if (!permission.id || resolved.has(permission.id)) return;
101
- if (activeId === permission.id) return;
102
- if (queue.some((item) => item.id === permission.id)) return;
103
- queue.push(permission);
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