opencode-landstrip 0.14.2 → 0.15.1

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 +48 -116
  2. package/package.json +2 -2
  3. package/shared.ts +187 -2
  4. package/tui.ts +259 -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'; fields: 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, 14, 5] as const;
58
+ const LANDSTRIP_VERSION = [0, 15, 8] 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));
@@ -245,17 +237,17 @@ function extractBlockedPath(
245
237
  if (match) 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) {
250
- if (trap.kind === 'Filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
251
- if (trap.kind === 'Internal' && trap.fields.file) {
252
- return normalizeBlockedPath(trap.fields.file, baseDirectory);
240
+ const landstripTraps = parseLandstripTraps(output);
241
+ for (const trap of landstripTraps) {
242
+ if (trap.kind === 'filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
243
+ if (trap.kind === 'internal' && trap.detail.file) {
244
+ return normalizeBlockedPath(trap.detail.file, baseDirectory);
253
245
  }
254
246
  }
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;
@@ -405,96 +397,6 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
405
397
  return true;
406
398
  }
407
399
 
408
- function decodeLandstripTrap(value: unknown): LandstripTrap | null {
409
- if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
410
- const entries = Object.entries(value as Record<string, unknown>);
411
- if (entries.length !== 1) return null;
412
- const [kind, payload] = entries[0];
413
-
414
- switch (kind) {
415
- case 'Filesystem': {
416
- if (!Array.isArray(payload)) return null;
417
- const [operation, path, mechanism] = payload;
418
- if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
419
- return { kind, operation, path, mechanism: typeof mechanism === 'string' ? mechanism : '' };
420
- }
421
- case 'Network': {
422
- if (!Array.isArray(payload)) return null;
423
- const [operation, target, mechanism] = payload;
424
- if (typeof operation !== 'string' || typeof target !== 'string') return null;
425
- return { kind, operation, target, mechanism: typeof mechanism === 'string' ? mechanism : '' };
426
- }
427
- case 'Launch': {
428
- if (!Array.isArray(payload)) return null;
429
- const [program, message] = payload;
430
- if (typeof program !== 'string') return null;
431
- return { kind, program, message: typeof message === 'string' ? message : '' };
432
- }
433
- case 'Usage': {
434
- if (typeof payload !== 'string') return null;
435
- return { kind, message: payload };
436
- }
437
- case 'Internal': {
438
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null;
439
- const fields: Record<string, string> = {};
440
- for (const [key, val] of Object.entries(payload as Record<string, unknown>)) {
441
- fields[key] = typeof val === 'string' ? val : JSON.stringify(val);
442
- }
443
- return { kind, fields };
444
- }
445
- default:
446
- return null;
447
- }
448
- }
449
-
450
- function parseLandstripErrors(output: string): LandstripTrap[] {
451
- const traps: LandstripTrap[] = [];
452
-
453
- for (const line of output.split('\n')) {
454
- const trimmed = line.trim();
455
- if (trimmed.length === 0 || trimmed[0] !== '{') continue;
456
-
457
- let parsed: unknown;
458
- try {
459
- parsed = JSON.parse(trimmed);
460
- } catch {
461
- continue;
462
- }
463
-
464
- const trap = decodeLandstripTrap(parsed);
465
- if (trap) traps.push(trap);
466
- }
467
-
468
- return traps;
469
- }
470
-
471
- function formatLandstripTrap(trap: LandstripTrap): string {
472
- switch (trap.kind) {
473
- case 'Filesystem':
474
- return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
475
- trap.mechanism ? ` [${trap.mechanism}]` : ''
476
- }`;
477
- case 'Network':
478
- return `landstrip: network ${trap.operation} denied (${trap.target})${
479
- trap.mechanism ? ` [${trap.mechanism}]` : ''
480
- }`;
481
- case 'Launch':
482
- return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
483
- case 'Usage':
484
- return `landstrip: usage error: ${trap.message}`;
485
- case 'Internal': {
486
- const detail = Object.entries(trap.fields)
487
- .map(([key, val]) => `${key}: ${val}`)
488
- .join(', ');
489
- return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
490
- }
491
- }
492
- }
493
-
494
- function formatLandstripErrors(traps: LandstripTrap[]): string {
495
- return traps.map(formatLandstripTrap).join('\n');
496
- }
497
-
498
400
  function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
499
401
  const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
500
402
  if (bracketMatch) {
@@ -728,15 +630,37 @@ function shellArgs(shell: string, command: string): string[] {
728
630
  return [shell, '-lc', command];
729
631
  }
730
632
 
731
- function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
732
- const args = ['-p', policyPath, ...shellArgs(shell, command)];
633
+ // The query-response port is published by the TUI plugin and is Linux-only (the
634
+ // socket protocol exists only in landstrip's seccomp broker).
635
+ function socketQueryPort(baseDirectory: string): number | null {
636
+ if (process.platform !== 'linux') return null;
637
+ return readDiscoveryPort(baseDirectory);
638
+ }
733
639
 
734
- return [landstripBinaryPath(), ...args].map(shellQuote).join(' ');
640
+ function buildWrappedCommand(
641
+ policyPath: string,
642
+ shell: string,
643
+ command: string,
644
+ trapPort: number | null,
645
+ ): string {
646
+ const baseArgs = ['-p', policyPath, ...shellArgs(shell, command)];
647
+ const plain = [landstripBinaryPath(), ...baseArgs].map(shellQuote).join(' ');
648
+ if (trapPort === null) return plain;
649
+
650
+ // Connect fd 3 to the TUI's query-response socket BEFORE landstrip applies the
651
+ // sandbox, so a denied write can be approved live instead of forcing a re-run.
652
+ // The `&&`/`||` guard falls back to the plain (no --trap-fd) invocation when the
653
+ // redirect fails — a dead port, a non-bash outer shell without /dev/tcp, or an
654
+ // outer `set -e` — so a failed connect never hands the broker a dead fd 3.
655
+ const trapped = [landstripBinaryPath(), '--trap-fd', '3', ...baseArgs].map(shellQuote).join(' ');
656
+ return `{ exec 3<>/dev/tcp/127.0.0.1/${trapPort} ; } 2>/dev/null && ${trapped} || ${plain}`;
735
657
  }
736
658
 
737
659
  function isGeneratedWrappedCommand(command: string): boolean {
738
660
  return (
739
- command.startsWith(`${shellQuote(landstripBinaryPath())} `) &&
661
+ // `.includes` rather than `.startsWith`: the query-response form prefixes a
662
+ // `{ exec 3<>/dev/tcp/...; } && ` redirect before the landstrip invocation.
663
+ command.includes(`${shellQuote(landstripBinaryPath())} `) &&
740
664
  command.includes(` ${shellQuote('-p')} `) &&
741
665
  command.includes('opencode-landstrip-')
742
666
  );
@@ -780,7 +704,10 @@ function extractOriginalCommand(wrappedCommand: string): string | null {
780
704
  const flagIdx = pIdx + 3;
781
705
  const flag = args[flagIdx];
782
706
  if (flag !== '-lc' && flag !== '-c') return null;
783
- return args.slice(flagIdx + 1).join(' ');
707
+ // The query-response form appends `|| <plain invocation>`; stop at that
708
+ // separator so the fallback branch is not folded into the recovered command.
709
+ const end = args.indexOf('||', flagIdx + 1);
710
+ return (end === -1 ? args.slice(flagIdx + 1) : args.slice(flagIdx + 1, end)).join(' ');
784
711
  }
785
712
 
786
713
  function getToolPath(args: Record<string, unknown>): string | undefined {
@@ -1152,6 +1079,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1152
1079
  policy.path,
1153
1080
  configuredShell ?? process.env.SHELL ?? '/bin/sh',
1154
1081
  originalCommand,
1082
+ socketQueryPort(directory),
1155
1083
  );
1156
1084
 
1157
1085
  activeBash.set(callID, {
@@ -1317,9 +1245,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1317
1245
  }
1318
1246
 
1319
1247
  const outputText = output?.output ?? '';
1320
- const errors = parseLandstripErrors(outputText);
1248
+ // Query traps were already resolved interactively over the socket by the
1249
+ // TUI plugin; only terminal (info) traps belong in the after-the-fact toast.
1250
+ const errors = parseLandstripTraps(outputText).filter(
1251
+ (trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
1252
+ );
1321
1253
  if (errors.length > 0) {
1322
- const message = formatLandstripErrors(errors);
1254
+ const message = formatLandstripTraps(errors);
1323
1255
  await client.tui
1324
1256
  ?.showToast?.({
1325
1257
  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.14.2",
3
+ "version": "0.15.1",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -49,7 +49,7 @@
49
49
  "ci:test": "npm test"
50
50
  },
51
51
  "dependencies": {
52
- "@landstrip/landstrip": "^0.14.5"
52
+ "@landstrip/landstrip": "^0.15.8"
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 {
@@ -316,3 +317,187 @@ 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 state = decodeTrapState(value.state);
362
+ const queryId = typeof value.query_id === 'number' ? value.query_id : undefined;
363
+ return { kind: 'filesystem', operation, path, mechanism, state, queryId };
364
+ }
365
+ case 'network': {
366
+ const { operation, target } = value;
367
+ if (typeof operation !== 'string' || typeof target !== 'string') return null;
368
+ return { kind: 'network', operation, target, mechanism };
369
+ }
370
+ case 'launch': {
371
+ const { program, message } = value;
372
+ if (typeof program !== 'string') return null;
373
+ return { kind: 'launch', program, message: typeof message === 'string' ? message : '' };
374
+ }
375
+ case 'usage': {
376
+ const { message } = value;
377
+ if (typeof message !== 'string') return null;
378
+ return { kind: 'usage', message };
379
+ }
380
+ case 'internal': {
381
+ const detail: Record<string, string> = {};
382
+ const payload = value.detail;
383
+ if (isRecord(payload)) {
384
+ for (const [key, val] of Object.entries(payload)) {
385
+ detail[key] = typeof val === 'string' ? val : JSON.stringify(val);
386
+ }
387
+ }
388
+ return { kind: 'internal', detail };
389
+ }
390
+ default:
391
+ return null;
392
+ }
393
+ }
394
+
395
+ export function parseLandstripTraps(output: string): LandstripTrap[] {
396
+ const traps: LandstripTrap[] = [];
397
+
398
+ for (const line of output.split('\n')) {
399
+ const trimmed = line.trim();
400
+ if (trimmed.length === 0 || trimmed[0] !== '{') continue;
401
+
402
+ let parsed: unknown;
403
+ try {
404
+ parsed = JSON.parse(trimmed);
405
+ } catch {
406
+ continue;
407
+ }
408
+
409
+ const trap = decodeLandstripTrap(parsed);
410
+ if (trap) traps.push(trap);
411
+ }
412
+
413
+ return traps;
414
+ }
415
+
416
+ export function formatLandstripTrap(trap: LandstripTrap): string {
417
+ switch (trap.kind) {
418
+ case 'filesystem':
419
+ return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
420
+ trap.mechanism ? ` [${trap.mechanism}]` : ''
421
+ }`;
422
+ case 'network':
423
+ return `landstrip: network ${trap.operation} denied (${trap.target})${
424
+ trap.mechanism ? ` [${trap.mechanism}]` : ''
425
+ }`;
426
+ case 'launch':
427
+ return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
428
+ case 'usage':
429
+ return `landstrip: usage error: ${trap.message}`;
430
+ case 'internal': {
431
+ const detail = Object.entries(trap.detail)
432
+ .map(([key, val]) => `${key}: ${val}`)
433
+ .join(', ');
434
+ return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
435
+ }
436
+ }
437
+ }
438
+
439
+ export function formatLandstripTraps(traps: LandstripTrap[]): string {
440
+ return traps.map(formatLandstripTrap).join('\n');
441
+ }
442
+
443
+ // The TUI plugin runs the query-response socket server and publishes its port
444
+ // to a per-directory discovery file; the server plugin reads it to inject the
445
+ // fd-3 redirect. Namespacing by a hash of the realpath keeps concurrent
446
+ // opencode instances in different projects from colliding.
447
+ function discoveryDir(): string {
448
+ const base = process.env.XDG_RUNTIME_DIR || tmpdir();
449
+ return join(base, 'opencode-landstrip');
450
+ }
451
+
452
+ export function discoveryFilePath(baseDirectory: string): string {
453
+ let key = baseDirectory;
454
+ try {
455
+ key = realpathSync.native(baseDirectory);
456
+ } catch {
457
+ // Directory not resolvable — hash the raw path instead.
458
+ }
459
+ const hash = createHash('sha256').update(key).digest('hex').slice(0, 16);
460
+ return join(discoveryDir(), `port-${hash}.json`);
461
+ }
462
+
463
+ export function writeDiscoveryPort(baseDirectory: string, port: number): void {
464
+ const dir = discoveryDir();
465
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
466
+ writeFileSync(
467
+ discoveryFilePath(baseDirectory),
468
+ JSON.stringify({ port, pid: process.pid, ts: Date.now() }) + '\n',
469
+ );
470
+ }
471
+
472
+ export function removeDiscoveryFile(baseDirectory: string): void {
473
+ rmSync(discoveryFilePath(baseDirectory), { force: true });
474
+ }
475
+
476
+ // Returns the live query-response port, or null when no fresh server is
477
+ // listening. A recorded writer pid that no longer exists marks the file stale.
478
+ export function readDiscoveryPort(baseDirectory: string): number | null {
479
+ const path = discoveryFilePath(baseDirectory);
480
+ if (!existsSync(path)) return null;
481
+
482
+ try {
483
+ const data: unknown = JSON.parse(readFileSync(path, 'utf-8'));
484
+ if (!isRecord(data)) return null;
485
+
486
+ const port = typeof data.port === 'number' ? data.port : NaN;
487
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
488
+
489
+ if (typeof data.pid === 'number') {
490
+ try {
491
+ process.kill(data.pid, 0);
492
+ } catch (error) {
493
+ // ESRCH: the writer is gone, so the file is stale. EPERM: alive but
494
+ // owned by another user — still a live listener, so accept it.
495
+ if ((error as NodeJS.ErrnoException).code === 'ESRCH') return null;
496
+ }
497
+ }
498
+
499
+ return port;
500
+ } catch {
501
+ return null;
502
+ }
503
+ }
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,27 @@ interface PendingPermission {
31
35
 
32
36
  type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
33
37
 
38
+ type WriteChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
39
+
40
+ // A landstrip write query held pending over the fd-3 socket. It shares the
41
+ // dialog stack with permission prompts so the two never overlap, hence the
42
+ // common `id`/`kind` shape.
43
+ interface WriteQueryEntry {
44
+ kind: 'write-query';
45
+ id: string;
46
+ socket: NetSocket;
47
+ queryId: number;
48
+ path: string;
49
+ }
50
+
51
+ interface PermissionEntry {
52
+ kind: 'permission';
53
+ id: string;
54
+ permission: PendingPermission;
55
+ }
56
+
57
+ type QueueEntry = PermissionEntry | WriteQueryEntry;
58
+
34
59
  function list(values: string[]): string {
35
60
  return values.join(', ') || '(none)';
36
61
  }
@@ -84,26 +109,44 @@ const tui: TuiPlugin = async (api, options, meta) => {
84
109
  // Permission requests can arrive twice (the live event and a reconnect replay
85
110
  // of `api.state`), so `resolved` tracks ids we have already answered and
86
111
  // `activeId` guards against stacking a second sandbox dialog on the first.
112
+ // Write queries share the same queue so a held-write prompt never stacks on a
113
+ // permission prompt.
87
114
  const resolved = new Set<string>();
88
- const queue: PendingPermission[] = [];
115
+ const queue: QueueEntry[] = [];
89
116
  let activeId: string | undefined;
90
117
 
118
+ // Paths the user approved "for session": later queries for the same path are
119
+ // auto-allowed without a dialog. This lives only in the TUI process — the
120
+ // server regenerates the policy from on-disk config each run — so it affects
121
+ // only live socket decisions, not the static policy.
122
+ const sessionAllowedWritePaths = new Set<string>();
123
+
124
+ // Write queries still awaiting a response, so cleanup can release held
125
+ // syscalls instead of letting the child hang.
126
+ const liveQueries = new Set<WriteQueryEntry>();
127
+
91
128
  function pump(): void {
92
129
  if (activeId !== undefined) return;
93
130
  let next = queue.shift();
94
131
  while (next && resolved.has(next.id)) next = queue.shift();
95
132
  if (!next) return;
96
- showPermission(next);
133
+ if (next.kind === 'permission') showPermission(next.permission);
134
+ else showWriteQuery(next);
97
135
  }
98
136
 
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);
137
+ function enqueueEntry(entry: QueueEntry): void {
138
+ if (!entry.id || resolved.has(entry.id)) return;
139
+ if (activeId === entry.id) return;
140
+ if (queue.some((item) => item.id === entry.id)) return;
141
+ queue.push(entry);
104
142
  pump();
105
143
  }
106
144
 
145
+ function enqueue(permission: PendingPermission): void {
146
+ if (!permission.id) return;
147
+ enqueueEntry({ kind: 'permission', id: permission.id, permission });
148
+ }
149
+
107
150
  // Safety net for missed/late events and reconnects: fold whatever the host
108
151
  // still considers pending for this session back into the queue.
109
152
  function reconcile(sessionID: string): void {
@@ -224,6 +267,107 @@ const tui: TuiPlugin = async (api, options, meta) => {
224
267
  );
225
268
  }
226
269
 
270
+ function respondWriteQuery(socket: NetSocket, queryId: number, action: 'allow' | 'deny'): void {
271
+ if (!socket.destroyed) socket.write(JSON.stringify({ query_id: queryId, action }) + '\n');
272
+ }
273
+
274
+ function resolveWriteQuery(entry: WriteQueryEntry, choice: WriteChoice): void {
275
+ if (resolved.has(entry.id)) return;
276
+ const action = choice === 'deny' ? 'deny' : 'allow';
277
+
278
+ try {
279
+ if (action === 'allow') {
280
+ if (choice === 'session') {
281
+ sessionAllowedWritePaths.add(entry.path);
282
+ } else if (choice === 'project' || choice === 'global') {
283
+ const directory = api.state.path.directory || process.cwd();
284
+ const { globalPath, projectPath } = getConfigPaths(directory);
285
+ const update = updateForPermission({
286
+ permission: 'write',
287
+ metadata: { filepath: entry.path },
288
+ });
289
+ if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
290
+ }
291
+ }
292
+
293
+ respondWriteQuery(entry.socket, entry.queryId, action);
294
+ api.ui.toast({
295
+ title: 'Sandbox',
296
+ message: action === 'deny' ? `Write denied: ${entry.path}` : `Write allowed (${choice})`,
297
+ variant: action === 'deny' ? 'warning' : 'success',
298
+ });
299
+ } catch {
300
+ // Persisting failed — still release the held syscall by denying it.
301
+ respondWriteQuery(entry.socket, entry.queryId, 'deny');
302
+ } finally {
303
+ liveQueries.delete(entry);
304
+ finishActive(entry.id);
305
+ }
306
+ }
307
+
308
+ function showWriteQuery(entry: WriteQueryEntry): void {
309
+ activeId = entry.id;
310
+
311
+ void api.attention.notify({
312
+ title: 'Sandbox write blocked',
313
+ message: entry.path,
314
+ sound: { name: 'permission' },
315
+ notification: true,
316
+ });
317
+
318
+ // A selection pops the dialog (firing `onClose`); track it so the esc-path
319
+ // deny does not override the user's choice. A held syscall must always be
320
+ // answered, so esc/dismiss denies rather than leaving it unresolved.
321
+ let selectionMade = false;
322
+
323
+ api.ui.dialog.replace(
324
+ () =>
325
+ api.ui.DialogSelect<WriteChoice>({
326
+ title: 'Sandbox Write Blocked',
327
+ placeholder: `Write blocked: ${entry.path}`,
328
+ options: [
329
+ {
330
+ title: 'Allow once',
331
+ value: 'once',
332
+ category: 'This write',
333
+ description: 'Permit this write and continue',
334
+ },
335
+ {
336
+ title: 'Allow for session',
337
+ value: 'session',
338
+ category: 'This write',
339
+ description: 'Permit writes to this path for the rest of this session',
340
+ },
341
+ {
342
+ title: 'Allow for project',
343
+ value: 'project',
344
+ category: 'Persist to sandbox.json',
345
+ description: 'Add to .opencode/sandbox.json allowWrite and permit',
346
+ },
347
+ {
348
+ title: 'Allow globally',
349
+ value: 'global',
350
+ category: 'Persist to sandbox.json',
351
+ description: 'Add to ~/.config/opencode/sandbox.json allowWrite and permit',
352
+ },
353
+ {
354
+ title: 'Deny',
355
+ value: 'deny',
356
+ category: 'Deny',
357
+ description: 'Block this write',
358
+ },
359
+ ],
360
+ onSelect: (option) => {
361
+ selectionMade = true;
362
+ resolveWriteQuery(entry, option.value);
363
+ },
364
+ }),
365
+ () => {
366
+ if (!selectionMade) resolveWriteQuery(entry, 'deny');
367
+ },
368
+ );
369
+ }
370
+
227
371
  const unsubscribeAsked = api.event.on('permission.asked', (event) => {
228
372
  const pending = event.properties as PendingPermission;
229
373
  enqueue(pending);
@@ -234,6 +378,98 @@ const tui: TuiPlugin = async (api, options, meta) => {
234
378
  finishActive(event.properties.requestID);
235
379
  });
236
380
 
381
+ // Query-response socket server (Linux-only — landstrip's socket protocol lives
382
+ // in the seccomp broker). The server plugin connects each sandboxed run's
383
+ // fd 3 here via a /dev/tcp redirect and we answer held writes interactively.
384
+ const sockets = new Set<NetSocket>();
385
+ let socketServer: ReturnType<typeof createServer> | undefined;
386
+
387
+ if (process.platform === 'linux') {
388
+ const baseDirectory = api.state.path.directory || process.cwd();
389
+ let socketSeq = 0;
390
+
391
+ socketServer = createServer((socket) => {
392
+ sockets.add(socket);
393
+ socket.setEncoding('utf8');
394
+ const socketId = ++socketSeq;
395
+ const seen = new Set<number>();
396
+ let buffer = '';
397
+
398
+ socket.on('data', (chunk: string | Buffer) => {
399
+ buffer += chunk.toString();
400
+ if (buffer.length > 1024 * 1024) {
401
+ socket.destroy();
402
+ return;
403
+ }
404
+
405
+ let newline: number;
406
+ while ((newline = buffer.indexOf('\n')) !== -1) {
407
+ const line = buffer.slice(0, newline);
408
+ buffer = buffer.slice(newline + 1);
409
+
410
+ for (const trap of parseLandstripTraps(line)) {
411
+ if (
412
+ trap.kind !== 'filesystem' ||
413
+ trap.state !== 'query' ||
414
+ trap.operation !== 'write'
415
+ ) {
416
+ continue;
417
+ }
418
+ if (typeof trap.queryId !== 'number' || seen.has(trap.queryId)) continue;
419
+ seen.add(trap.queryId);
420
+
421
+ if (sessionAllowedWritePaths.has(trap.path)) {
422
+ respondWriteQuery(socket, trap.queryId, 'allow');
423
+ continue;
424
+ }
425
+
426
+ const entry: WriteQueryEntry = {
427
+ kind: 'write-query',
428
+ id: `landstrip-write:${socketId}:${trap.queryId}`,
429
+ socket,
430
+ queryId: trap.queryId,
431
+ path: trap.path,
432
+ };
433
+ liveQueries.add(entry);
434
+ enqueueEntry(entry);
435
+ }
436
+ }
437
+ });
438
+
439
+ const cleanup = () => {
440
+ sockets.delete(socket);
441
+ // The child is gone; drop our holds for this socket so the queue moves on.
442
+ // Deleting the current entry mid-iteration is well-defined for a Set.
443
+ for (const entry of liveQueries) {
444
+ if (entry.socket !== socket) continue;
445
+ liveQueries.delete(entry);
446
+ finishActive(entry.id);
447
+ }
448
+ };
449
+ socket.on('error', cleanup);
450
+ socket.on('close', cleanup);
451
+ });
452
+
453
+ socketServer.on('error', () => {
454
+ try {
455
+ removeDiscoveryFile(baseDirectory);
456
+ } catch {
457
+ // best effort
458
+ }
459
+ });
460
+
461
+ socketServer.listen(0, '127.0.0.1', () => {
462
+ const address = socketServer?.address() as AddressInfo | null;
463
+ if (address && typeof address === 'object') {
464
+ try {
465
+ writeDiscoveryPort(baseDirectory, address.port);
466
+ } catch {
467
+ // best effort — falls back to the server's reset model
468
+ }
469
+ }
470
+ });
471
+ }
472
+
237
473
  const showSandbox = () => {
238
474
  const directory = api.state.path.directory || process.cwd();
239
475
  const message = sandboxSummary(directory, optionOverrides);
@@ -335,6 +571,22 @@ const tui: TuiPlugin = async (api, options, meta) => {
335
571
  api.lifecycle.onDispose(() => {
336
572
  unsubscribeAsked();
337
573
  unsubscribeReplied();
574
+
575
+ // Deny any still-held writes so the sandboxed children don't hang, then
576
+ // tear down the socket server and drop the discovery file.
577
+ for (const entry of liveQueries) {
578
+ respondWriteQuery(entry.socket, entry.queryId, 'deny');
579
+ liveQueries.delete(entry);
580
+ }
581
+ for (const socket of sockets) socket.destroy();
582
+ if (socketServer) {
583
+ socketServer.close();
584
+ try {
585
+ removeDiscoveryFile(api.state.path.directory || process.cwd());
586
+ } catch {
587
+ // best effort
588
+ }
589
+ }
338
590
  });
339
591
  };
340
592