opencode-landstrip 0.18.26 → 0.18.27

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 (5) hide show
  1. package/README.md +61 -29
  2. package/index.ts +45 -81
  3. package/package.json +2 -2
  4. package/shared.ts +79 -0
  5. package/tui.ts +141 -54
package/README.md CHANGED
@@ -1,13 +1,9 @@
1
1
  # opencode-landstrip
2
2
 
3
- `opencode-landstrip` sandboxes OpenCode AI `bash` calls with
4
- [`landstrip`](https://github.com/landstrip/landstrip) and the supported Anthropic
5
- Sandbox Runtime policy fields.
3
+ `opencode-landstrip` runs OpenCode AI `bash` calls through
4
+ [`landstrip`](https://github.com/landstrip/landstrip).
6
5
 
7
- The bundled [policy](./sandbox.json) is merged with global, project, and plugin
8
- options in that order.
9
-
10
- ## Installation
6
+ ## Install
11
7
 
12
8
  ```sh
13
9
  # Current project
@@ -17,7 +13,8 @@ opencode plugin install opencode-landstrip
17
13
  opencode plugin install opencode-landstrip --global
18
14
  ```
19
15
 
20
- For a manual install, add the server plugin to `opencode.json`:
16
+ A manual installation needs both plugins. Add the server plugin to
17
+ `opencode.json`:
21
18
 
22
19
  ```json
23
20
  {
@@ -26,7 +23,7 @@ For a manual install, add the server plugin to `opencode.json`:
26
23
  }
27
24
  ```
28
25
 
29
- Add the TUI plugin to `tui.json`:
26
+ Add the presenter to `tui.json`:
30
27
 
31
28
  ```json
32
29
  {
@@ -35,31 +32,66 @@ Add the TUI plugin to `tui.json`:
35
32
  }
36
33
  ```
37
34
 
38
- Run `/sandbox` to inspect or toggle the sandbox. The toggle writes to the
39
- project config if it already defines `enabled`; otherwise it writes to the
40
- global config.
35
+ ## Permissions
36
+
37
+ OpenCode agent permissions authorize tool dispatch. Landstrip sandbox
38
+ permissions authorize filesystem and network access before a tool or while its
39
+ process runs. Agent approval never bypasses a sandbox hard denial.
40
+
41
+ The TUI presents one request at a time. OpenCode's native agent prompt has
42
+ priority; Landstrip requests retain FIFO order and are routed to the originating
43
+ root session. Without a live presenter, blocked sandbox requests are denied.
44
+
45
+ Filesystem approvals may be stored in `.opencode/sandbox.json` for the project
46
+ or `~/.config/opencode/sandbox.json` globally. `/sandbox` displays or toggles the
47
+ policy. It updates the project file when that file defines `enabled`, otherwise
48
+ the global file.
49
+
50
+ ## Configuration
51
+
52
+ Policy merges in this order:
53
+
54
+ 1. bundled [`sandbox.json`](./sandbox.json);
55
+ 2. `~/.config/opencode/sandbox.json`;
56
+ 3. `.opencode/sandbox.json`;
57
+ 4. plugin options.
58
+
59
+ Objects merge recursively, arrays combine, and later scalar values replace
60
+ earlier values. The global file is initialized from the bundled policy when it
61
+ does not exist. Programmatic plugin options may be the policy object directly or
62
+ `{ "config": { ... } }`.
63
+
64
+ | Field | Bundled default |
65
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
66
+ | `enabled` | `true` |
67
+ | `filesystem.denyRead` | `["/Users", "/home"]` |
68
+ | `filesystem.allowRead` | `[".", "~/.gitconfig", "~/.config/git/config", "/dev/null"]` |
69
+ | `filesystem.allowWrite` | `[".", "/dev/null"]` |
70
+ | `filesystem.denyWrite` | `["**/.env", "**/.env.*", "**/*.pem", "**/*.key", ".opencode/sandbox.json", "~/.config/opencode/sandbox.json"]` |
71
+ | `network.allowNetwork` | `false` |
72
+ | `network.allowLocalBinding` | `false` |
73
+ | `network.allowAllUnixSockets` | `false` |
74
+ | `network.allowUnixSockets` | `[]` |
75
+ | `network.allowedDomains` | `[]` |
76
+ | `network.deniedDomains` | `[]` |
41
77
 
42
- ## Behavior
78
+ Filesystem and core network semantics follow the main
79
+ [Landstrip policy](https://github.com/landstrip/landstrip#policy). `allowedDomains`
80
+ and `deniedDomains` are plugin-only fields enforced by its local HTTP/HTTPS proxy.
43
81
 
44
- AI `bash` calls run through Landstrip. Direct network access is blocked by
45
- default; the plugin's local HTTP and HTTPS proxy enforces `allowedDomains` and
46
- `deniedDomains`.
47
- The domain lists are plugin policy fields, not core Landstrip fields.
82
+ If `enabled` is true but the Landstrip binary, version, or platform is unusable,
83
+ AI Bash fails closed rather than running without isolation. Set `enabled` to
84
+ false explicitly to allow unsandboxed AI Bash.
48
85
 
49
- OpenCode handles normal tool permissions before dispatch. On Linux, Landstrip
50
- can also pause a blocked filesystem or network operation and show an inline
51
- approval prompt. Filesystem approvals may be saved to
52
- `.opencode/sandbox.json` or `~/.config/opencode/sandbox.json`.
86
+ ## Limits
53
87
 
54
- Shell-mode commands typed by the user are not process-sandboxed because
55
- OpenCode's plugin API cannot replace their execution. They may inherit proxy
56
- settings and receive policy checks, but they do not have an OS sandbox.
88
+ Only AI `bash` calls are process-sandboxed. Shell-mode commands entered directly
89
+ by the user cannot be replaced through OpenCode's plugin API. They may inherit
90
+ proxy settings and receive policy checks, but they do not have OS isolation.
57
91
 
58
- See the main [Landstrip documentation](https://github.com/landstrip/landstrip#readme)
59
- for policy semantics and platform limits.
92
+ See the main [Landstrip documentation](https://github.com/landstrip/landstrip#readme) for platform limits.
60
93
 
61
94
  ## License
62
95
 
63
- `opencode-landstrip` is licensed under `Apache-2.0`. See [LICENSE](LICENSE).
64
- The bundled `@landstrip/landstrip` package is licensed separately as
65
- `Apache-2.0 AND LGPL-2.1-or-later`.
96
+ `opencode-landstrip` is licensed under `Apache-2.0`. The bundled Landstrip
97
+ package is licensed separately as `Apache-2.0 AND LGPL-2.1-or-later`.
package/index.ts CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  permissionType,
38
38
  sandboxSummary,
39
39
  readDiscoveryPort,
40
- sessionScopeFor,
40
+ trapSessionHelloLine,
41
41
  } from './shared.js';
42
42
 
43
43
  type LandstripPolicy = {
@@ -50,6 +50,7 @@ type LandstripPolicy = {
50
50
  interface BashSandboxState {
51
51
  originalCommand: string;
52
52
  wrappedCommand: string;
53
+ sessionID: string | undefined;
53
54
  policyDir: string;
54
55
  port: number | null;
55
56
  proxyToken: string | null;
@@ -794,9 +795,6 @@ function startTrapServer(
794
795
  denyRead: string[],
795
796
  denyWrite: string[],
796
797
  baseDirectory: string,
797
- sessionAllowedReadPaths: Set<string>,
798
- sessionAllowedWritePaths: Set<string>,
799
- sessionAllowedTargets: Set<string>,
800
798
  ): Promise<{ server: ReturnType<typeof createServer>; port: number; trapLines: string[] }> {
801
799
  const trapLines: string[] = [];
802
800
  const server = createServer((trapSocket) => {
@@ -831,11 +829,6 @@ function startTrapServer(
831
829
  if (allowed) {
832
830
  trapSocket.write(controlResponseLine(queryId, 'allow'));
833
831
  } else {
834
- // Remember the scope for later commands, but deny this syscall so
835
- // a command that may already have side effects is never retried.
836
- const scope = sessionScopeFor(path, baseDirectory);
837
- if (operation === 'read') sessionAllowedReadPaths.add(scope);
838
- else sessionAllowedWritePaths.add(scope);
839
832
  trapSocket.write(controlResponseLine(queryId, 'deny'));
840
833
  trapLines.push(line);
841
834
  }
@@ -843,17 +836,8 @@ function startTrapServer(
843
836
  trap.kind === 'network' &&
844
837
  (trap.operation === 'connect' || trap.operation === 'bind')
845
838
  ) {
846
- // No policy field expresses "allow this address:port" (allowedDomains
847
- // is hostname-based and enforced by the HTTP(S) proxy, not the broker),
848
- // so — matching the filesystem branch above — auto-grant, remember it
849
- // for the rest of the session, and surface it afterward rather than
850
- // hanging or hard-denying an already-approved command.
851
- const target = trap.target;
852
- if (!sessionAllowedTargets.has(target)) {
853
- sessionAllowedTargets.add(target);
854
- trapLines.push(line);
855
- }
856
- trapSocket.write(controlResponseLine(queryId, 'allow'));
839
+ trapSocket.write(controlResponseLine(queryId, 'deny'));
840
+ trapLines.push(line);
857
841
  } else {
858
842
  trapLines.push(line);
859
843
  }
@@ -905,6 +889,7 @@ function buildWrappedCommand(
905
889
  shell: string,
906
890
  command: string,
907
891
  trapPort: number | null,
892
+ sessionID?: string,
908
893
  ): string {
909
894
  const baseArgs = ['run', '-p', policyPath, '--', ...shellArgs(shell, command)];
910
895
  const plain = [landstripBinaryPath(), ...baseArgs].map(shellQuote).join(' ');
@@ -926,7 +911,10 @@ function buildWrappedCommand(
926
911
  ]
927
912
  .map(shellQuote)
928
913
  .join(' ');
929
- const openTrap = `exec 3<>/dev/tcp/127.0.0.1/${trapPort} && exec "$@"`;
914
+ const identifySession = sessionID
915
+ ? ` && printf '%s' ${shellQuote(trapSessionHelloLine(sessionID))} >&3`
916
+ : '';
917
+ const openTrap = `exec 3<>/dev/tcp/127.0.0.1/${trapPort}${identifySession} && exec "$@"`;
930
918
  return `bash -c ${shellQuote(openTrap)} bash ${trapped}`;
931
919
  }
932
920
 
@@ -1065,21 +1053,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1065
1053
  const activeBash = new Map<string, BashSandboxState>();
1066
1054
  const notified = new Set<string>();
1067
1055
  const callAllowances = new Set<string>();
1068
- const sessionAllowedReadPaths = new Set<string>();
1069
- const sessionAllowedWritePaths = new Set<string>();
1070
- const sessionAllowedTargets = new Set<string>();
1071
-
1072
- function mergeAllowances(configured: string[], session: Set<string>): string[] {
1073
- return [...configured, ...session];
1074
- }
1075
-
1076
- function getEffectiveAllowRead(config: SandboxConfig): string[] {
1077
- return mergeAllowances(config.filesystem.allowRead, sessionAllowedReadPaths);
1078
- }
1079
-
1080
- function getEffectiveAllowWrite(config: SandboxConfig): string[] {
1081
- return mergeAllowances(config.filesystem.allowWrite, sessionAllowedWritePaths);
1082
- }
1083
1056
  let enabledNotified = false;
1084
1057
  let configuredShell: string | undefined;
1085
1058
  let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
@@ -1250,12 +1223,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1250
1223
 
1251
1224
  const check = checkLandstrip();
1252
1225
  if (!check?.ok) {
1253
- await notifyOnce(
1254
- `disabled:${check?.reason ?? 'unknown'}`,
1255
- check?.reason ?? 'Sandbox disabled',
1256
- 'error',
1257
- );
1258
- return null;
1226
+ const reason = check?.reason ?? 'Unknown Landstrip installation error';
1227
+ await notifyOnce(`broken-installation:${reason}`, reason, 'error');
1228
+ throw new Error(`Broken @landstrip/landstrip installation: ${reason}`);
1259
1229
  }
1260
1230
 
1261
1231
  if (!enabledNotified) {
@@ -1308,10 +1278,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1308
1278
 
1309
1279
  async function prepareBash(
1310
1280
  callID: string,
1281
+ sessionID: string | undefined,
1311
1282
  args: Record<string, unknown>,
1312
1283
  config: SandboxConfig,
1313
1284
  ): Promise<void> {
1314
1285
  if (typeof args.command !== 'string') return;
1286
+ const normalizedSessionID = sessionID?.trim() || undefined;
1315
1287
 
1316
1288
  const rewriteDescription = (): void => {
1317
1289
  if (typeof args.description === 'string')
@@ -1320,7 +1292,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1320
1292
 
1321
1293
  const existing = activeBash.get(callID);
1322
1294
  if (existing) {
1323
- if (args.command === existing.originalCommand || args.command === existing.wrappedCommand) {
1295
+ if (
1296
+ existing.sessionID === normalizedSessionID &&
1297
+ (args.command === existing.originalCommand || args.command === existing.wrappedCommand)
1298
+ ) {
1324
1299
  args.command = existing.wrappedCommand;
1325
1300
  rewriteDescription();
1326
1301
  return;
@@ -1330,16 +1305,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1330
1305
  }
1331
1306
 
1332
1307
  if (isGeneratedWrappedCommand(args.command as string)) {
1333
- const policyMatch = (args.command as string).match(/\s'-p'\s+'([^']+)'/);
1334
- if (policyMatch?.[1] && existsSync(policyMatch[1])) {
1335
- rewriteDescription();
1336
- return;
1337
- }
1338
1308
  if (activeBash.has(callID)) await cleanupBash(callID);
1339
1309
  const original = extractOriginalCommand(args.command as string);
1340
- if (original) {
1341
- args.command = original;
1342
- }
1310
+ if (original) args.command = original;
1343
1311
  }
1344
1312
 
1345
1313
  const allowNetwork = config.network.allowNetwork;
@@ -1347,11 +1315,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1347
1315
  const effectiveConfig = {
1348
1316
  ...config,
1349
1317
  network: { ...config.network },
1350
- filesystem: {
1351
- ...config.filesystem,
1352
- allowRead: getEffectiveAllowRead(config),
1353
- allowWrite: getEffectiveAllowWrite(config),
1354
- },
1318
+ filesystem: config.filesystem,
1355
1319
  };
1356
1320
 
1357
1321
  if (!allowNetwork) {
@@ -1391,9 +1355,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1391
1355
  const originalCommand = args.command as string;
1392
1356
 
1393
1357
  // The TUI owns interactive query handling. Fall back to an in-process
1394
- // broker when no TUI endpoint is available (for example, in headless mode).
1358
+ // broker when no TUI endpoint or session identity is available.
1359
+ const interactiveSessionID = normalizedSessionID ?? '';
1395
1360
  const tuiTrapPort =
1396
- process.platform === 'linux' ? await readLiveDiscoveryPort(directory) : null;
1361
+ process.platform === 'linux' && interactiveSessionID
1362
+ ? await readLiveDiscoveryPort(directory)
1363
+ : null;
1397
1364
  const trapServer =
1398
1365
  tuiTrapPort === null
1399
1366
  ? await startTrapServer(
@@ -1402,9 +1369,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1402
1369
  effectiveConfig.filesystem.denyRead,
1403
1370
  effectiveConfig.filesystem.denyWrite,
1404
1371
  directory,
1405
- sessionAllowedReadPaths,
1406
- sessionAllowedWritePaths,
1407
- sessionAllowedTargets,
1408
1372
  )
1409
1373
  : null;
1410
1374
  const trapPort = tuiTrapPort ?? trapServer?.port ?? null;
@@ -1414,11 +1378,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1414
1378
  configuredShell ?? process.env.SHELL ?? '/bin/sh',
1415
1379
  originalCommand,
1416
1380
  trapPort,
1381
+ tuiTrapPort === null ? undefined : interactiveSessionID,
1417
1382
  );
1418
1383
 
1419
1384
  activeBash.set(callID, {
1420
1385
  originalCommand,
1421
1386
  wrappedCommand,
1387
+ sessionID: normalizedSessionID,
1422
1388
  policyDir: policy.dir,
1423
1389
  port: proxyPort,
1424
1390
  proxyToken,
@@ -1456,8 +1422,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1456
1422
  : undefined;
1457
1423
  const patterns = permissionPatterns(request);
1458
1424
 
1459
- const effectiveAllowRead = getEffectiveAllowRead(config);
1460
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1425
+ const effectiveAllowRead = config.filesystem.allowRead;
1426
+ const effectiveAllowWrite = config.filesystem.allowWrite;
1461
1427
  const args: Record<string, unknown> = { ...metadata };
1462
1428
  if (permission === 'read') args.paths = patterns;
1463
1429
  if (permission === 'edit') {
@@ -1489,7 +1455,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1489
1455
  if (!config) return;
1490
1456
 
1491
1457
  if (input.tool === 'bash') {
1492
- await prepareBash(input.callID, output.args, config);
1458
+ await prepareBash(input.callID, input.sessionID, output.args, config);
1493
1459
  return;
1494
1460
  }
1495
1461
 
@@ -1498,8 +1464,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1498
1464
  output.args,
1499
1465
  config,
1500
1466
  directory,
1501
- getEffectiveAllowRead(config),
1502
- getEffectiveAllowWrite(config),
1467
+ config.filesystem.allowRead,
1468
+ config.filesystem.allowWrite,
1503
1469
  );
1504
1470
  for (const decision of decisions) {
1505
1471
  enforcePermission(input.callID, decision);
@@ -1530,7 +1496,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1530
1496
  // the after-the-fact toast.
1531
1497
  const serverTrapOutput = state.trapLines.join('\n');
1532
1498
  const combinedOutput = serverTrapOutput ? outputText + '\n' + serverTrapOutput : outputText;
1533
- const errors = parseLandstripTraps(combinedOutput).filter(
1499
+ const traps = parseLandstripTraps(combinedOutput);
1500
+ const errors = traps.filter(
1534
1501
  (trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
1535
1502
  );
1536
1503
  if (errors.length > 0) {
@@ -1553,21 +1520,18 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1553
1520
  ?.catch?.(() => undefined);
1554
1521
  }
1555
1522
 
1556
- const blockedPath = extractBlockedPath(outputText, directory, state.originalCommand);
1523
+ const blockedTrap = traps.find(
1524
+ (trap): trap is Extract<LandstripTrap, { kind: 'filesystem' }> =>
1525
+ trap.kind === 'filesystem' && trap.state === 'query',
1526
+ );
1527
+ const blockedPath = blockedTrap
1528
+ ? canonicalizePath(blockedTrap.path, directory)
1529
+ : extractBlockedPath(outputText, directory, state.originalCommand);
1557
1530
  if (blockedPath) {
1558
- let blockedOperation: 'read' | 'write' = 'read';
1559
- for (const trap of errors) {
1560
- if (trap.kind === 'filesystem') {
1561
- blockedOperation = trap.operation;
1562
- break;
1563
- }
1564
- }
1565
- const scope = sessionScopeFor(blockedPath, directory);
1566
- if (blockedOperation === 'read') sessionAllowedReadPaths.add(scope);
1567
- else sessionAllowedWritePaths.add(scope);
1531
+ const blockedOperation = blockedTrap?.operation ?? 'read';
1568
1532
  await notifyOnce(
1569
1533
  `blocked:${blockedPath}`,
1570
- `Sandbox blocked ${blockedOperation} to "${blockedPath}". Added "${scope}" to session allowlist; retry the command.`,
1534
+ `Sandbox blocked ${blockedOperation} to "${blockedPath}". No live TUI presenter was available, so access remains denied.`,
1571
1535
  'warning',
1572
1536
  );
1573
1537
  }
@@ -1597,8 +1561,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1597
1561
  const config = await activeConfig();
1598
1562
  if (!config) return;
1599
1563
 
1600
- const effectiveAllowRead = getEffectiveAllowRead(config);
1601
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1564
+ const effectiveAllowRead = config.filesystem.allowRead;
1565
+ const effectiveAllowWrite = config.filesystem.allowWrite;
1602
1566
 
1603
1567
  for (const path of extractCandidatePaths(shellCommand)) {
1604
1568
  const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.18.26",
3
+ "version": "0.18.27",
4
4
  "description": "OS-level AI command sandboxing for OpenCode with Landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -48,7 +48,7 @@
48
48
  "ci:test": "npm test"
49
49
  },
50
50
  "dependencies": {
51
- "@landstrip/landstrip": "^0.18.26",
51
+ "@landstrip/landstrip": "^0.18.27",
52
52
  "@opentui/solid": ">=0.3.4",
53
53
  "solid-js": "1.9.12"
54
54
  },
package/shared.ts CHANGED
@@ -517,6 +517,85 @@ export function controlResponseLine(
517
517
  return JSON.stringify(response) + '\n';
518
518
  }
519
519
 
520
+ export interface TrapSessionHello {
521
+ kind: 'opencode-landstrip-session';
522
+ sessionID: string;
523
+ }
524
+
525
+ export interface SessionAllowances {
526
+ readPaths: Set<string>;
527
+ writePaths: Set<string>;
528
+ targets: Set<string>;
529
+ }
530
+
531
+ export function trapSessionHelloLine(sessionID: string): string {
532
+ const normalized = sessionID.trim();
533
+ if (!normalized) throw new Error('OpenCode session ID must not be empty');
534
+ const hello: TrapSessionHello = { kind: 'opencode-landstrip-session', sessionID: normalized };
535
+ return JSON.stringify(hello) + '\n';
536
+ }
537
+
538
+ export function decodeTrapSessionHello(line: string): TrapSessionHello | null {
539
+ let value: unknown;
540
+ try {
541
+ value = JSON.parse(line);
542
+ } catch {
543
+ return null;
544
+ }
545
+ if (!isRecord(value) || value.kind !== 'opencode-landstrip-session') return null;
546
+ if (typeof value.sessionID !== 'string') return null;
547
+ const sessionID = value.sessionID.trim();
548
+ return sessionID ? { kind: 'opencode-landstrip-session', sessionID } : null;
549
+ }
550
+
551
+ export function sessionAllowancesFor(
552
+ sessions: Map<string, SessionAllowances>,
553
+ sessionID: string,
554
+ ): SessionAllowances {
555
+ const existing = sessions.get(sessionID);
556
+ if (existing) return existing;
557
+ const created: SessionAllowances = {
558
+ readPaths: new Set(),
559
+ writePaths: new Set(),
560
+ targets: new Set(),
561
+ };
562
+ sessions.set(sessionID, created);
563
+ return created;
564
+ }
565
+
566
+ export function rootSessionIDFor(
567
+ sourceSessionID: string,
568
+ session: (sessionID: string) => { parentID?: string } | undefined,
569
+ ): string | undefined {
570
+ let current = sourceSessionID;
571
+ const seen = new Set<string>();
572
+ while (!seen.has(current)) {
573
+ seen.add(current);
574
+ const value = session(current);
575
+ if (!value) return undefined;
576
+ if (!value.parentID) return current;
577
+ current = value.parentID;
578
+ }
579
+ return undefined;
580
+ }
581
+
582
+ export function nextSandboxPermissionIndex<T extends { sessionID: string }>(
583
+ queue: readonly T[],
584
+ routeSessionID: string,
585
+ blocked: (entry: T) => boolean,
586
+ ): number {
587
+ const entry = queue[0];
588
+ return entry && entry.sessionID === routeSessionID && !blocked(entry) ? 0 : -1;
589
+ }
590
+
591
+ export function shouldRenderSandboxPermission(
592
+ requestSessionID: string,
593
+ routeSessionID: string,
594
+ hostPromptActive: boolean,
595
+ ): boolean {
596
+ return requestSessionID === routeSessionID && !hostPromptActive;
597
+ }
598
+
520
599
  // The TUI plugin runs the query-response socket server and publishes its port
521
600
  // to a per-directory discovery file; the server plugin reads it to inject the
522
601
  // fd-3 redirect. Namespacing by a hash of the realpath keeps concurrent
package/tui.ts CHANGED
@@ -14,19 +14,25 @@ import type {
14
14
  import { RGBA } from '@opentui/core';
15
15
  import { useTerminalDimensions } from '@opentui/solid';
16
16
  import { Fragment, jsx, jsxs } from '@opentui/solid/jsx-runtime';
17
- import { createSignal, onCleanup } from 'solid-js';
17
+ import { createSignal, onCleanup, onMount } from 'solid-js';
18
18
 
19
19
  import {
20
20
  controlResponseLine,
21
+ decodeTrapSessionHello,
21
22
  getConfigPaths,
22
23
  loadConfig,
23
24
  normalizeOptions,
25
+ nextSandboxPermissionIndex,
24
26
  parseLandstripTraps,
25
27
  removeDiscoveryFile,
26
28
  sessionAllows,
29
+ sessionAllowancesFor,
27
30
  sandboxSummary,
28
31
  sessionScopeFor,
32
+ rootSessionIDFor,
29
33
  setSandboxConfigEnabled,
34
+ shouldRenderSandboxPermission,
35
+ type SessionAllowances,
30
36
  updateForPermission,
31
37
  writeConfigFile,
32
38
  writeDiscoveryPort,
@@ -49,6 +55,7 @@ interface PermissionPromptProps<Value extends string> {
49
55
  options: readonly PromptOption<Value>[];
50
56
  onSelect: (value: Value) => void;
51
57
  onCancel: () => void;
58
+ onShow: () => void;
52
59
  }
53
60
 
54
61
  const promptBorderChars = {
@@ -74,6 +81,9 @@ interface FsQueryEntry {
74
81
  socket: NetSocket;
75
82
  queryId: string;
76
83
  operation: 'read' | 'write';
84
+ sessionID: string;
85
+ sourceSessionID: string;
86
+ directory: string;
77
87
  path: string;
78
88
  }
79
89
 
@@ -87,6 +97,9 @@ interface NetworkQueryEntry {
87
97
  socket: NetSocket;
88
98
  queryId: string;
89
99
  operation: string;
100
+ sessionID: string;
101
+ sourceSessionID: string;
102
+ directory: string;
90
103
  target: string;
91
104
  }
92
105
 
@@ -125,6 +138,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
125
138
  const theme = api.theme.current;
126
139
  const dimensions = useTerminalDimensions();
127
140
  const [selected, setSelected] = createSignal(0);
141
+ onMount(props.onShow);
128
142
 
129
143
  function move(direction: number): void {
130
144
  setSelected((index) => (index + direction + props.options.length) % props.options.length);
@@ -273,28 +287,51 @@ const tui: TuiPlugin = async (api, options, meta) => {
273
287
  const resolved = new Set<string>();
274
288
  const queue: QueueEntry[] = [];
275
289
  const [activeEntry, setActiveEntry] = createSignal<QueueEntry>();
290
+ const [permissionRevision, setPermissionRevision] = createSignal(0);
276
291
  let activeId: string | undefined;
277
292
  let refreshSandboxStatus: (() => void) | undefined;
278
293
 
279
- // Paths the user approved "for session": later queries for the same path are
280
- // auto-allowed without a dialog. This lives only in the TUI process — the
281
- // server regenerates the policy from on-disk config each run — so it affects
282
- // only live socket decisions, not the static policy.
283
- const sessionAllowedWritePaths = new Set<string>();
284
- const sessionAllowedReadPaths = new Set<string>();
285
-
286
- // Targets the user approved "for session": address:port, since the broker
287
- // knows nothing more specific than that at connect/bind time.
288
- const sessionAllowedTargets = new Set<string>();
294
+ const sessionAllowances = new Map<string, SessionAllowances>();
295
+ const notified = new Set<string>();
289
296
 
290
297
  // Filesystem and network queries still awaiting a response, so cleanup can
291
298
  // release held syscalls instead of letting the child hang.
292
299
  const liveQueries = new Set<FsQueryEntry | NetworkQueryEntry>();
293
300
 
301
+ function nativePermissionPending(entry: QueueEntry): boolean {
302
+ if (api.state.session.permission(entry.sessionID).length > 0) return true;
303
+ return (
304
+ entry.sourceSessionID !== entry.sessionID &&
305
+ api.state.session.permission(entry.sourceSessionID).length > 0
306
+ );
307
+ }
308
+
309
+ function currentRouteSessionID(): string | undefined {
310
+ const route = api.route.current;
311
+ if (route.name !== 'session' || !route.params) return undefined;
312
+ return typeof route.params.sessionID === 'string' ? route.params.sessionID : undefined;
313
+ }
314
+
294
315
  function pump(): void {
295
- if (activeId !== undefined) return;
296
- let next = queue.shift();
297
- while (next && resolved.has(next.id)) next = queue.shift();
316
+ const routeSessionID = currentRouteSessionID();
317
+ if (!routeSessionID) return;
318
+
319
+ const active = activeEntry();
320
+ if (activeId !== undefined) {
321
+ if (active?.sessionID === routeSessionID) return;
322
+ if (active && !resolved.has(active.id)) queue.unshift(active);
323
+ activeId = undefined;
324
+ setActiveEntry(undefined);
325
+ }
326
+
327
+ for (let index = queue.length - 1; index >= 0; index -= 1) {
328
+ const entry = queue[index];
329
+ if (entry && resolved.has(entry.id)) queue.splice(index, 1);
330
+ }
331
+
332
+ const index = nextSandboxPermissionIndex(queue, routeSessionID, nativePermissionPending);
333
+ if (index === -1) return;
334
+ const [next] = queue.splice(index, 1);
298
335
  if (!next) return;
299
336
  if (next.kind === 'fs-query') showFsQuery(next);
300
337
  else showNetworkQuery(next);
@@ -310,6 +347,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
310
347
 
311
348
  function finishActive(id: string): void {
312
349
  resolved.add(id);
350
+ notified.delete(id);
313
351
  if (activeId === id) {
314
352
  activeId = undefined;
315
353
  setActiveEntry(undefined);
@@ -317,19 +355,39 @@ const tui: TuiPlugin = async (api, options, meta) => {
317
355
  queueMicrotask(pump);
318
356
  }
319
357
 
358
+ function notifyQuery(entry: QueueEntry): void {
359
+ if (notified.has(entry.id)) return;
360
+ notified.add(entry.id);
361
+ void api.attention
362
+ .notify({
363
+ title: `Sandbox ${entry.operation} blocked`,
364
+ message: entry.kind === 'fs-query' ? entry.path : entry.target,
365
+ sound: { name: 'permission' },
366
+ notification: true,
367
+ })
368
+ .catch(() => undefined);
369
+ }
370
+
320
371
  function renderSessionPrompt(props: TuiHostSlotMap['session_prompt']) {
321
372
  return jsx(Fragment, {
322
373
  get children() {
374
+ permissionRevision();
375
+ queueMicrotask(pump);
323
376
  const entry = activeEntry();
324
- const directory =
325
- api.state.session.get(props.session_id)?.directory ||
326
- api.state.path.directory ||
327
- process.cwd();
328
- if (entry?.kind === 'fs-query') {
377
+ const hostPromptActive =
378
+ props.visible === false ||
379
+ Boolean(props.disabled) ||
380
+ Boolean(entry && nativePermissionPending(entry));
381
+ const showEntry = Boolean(
382
+ entry &&
383
+ shouldRenderSandboxPermission(entry.sessionID, props.session_id, hostPromptActive),
384
+ );
385
+
386
+ if (showEntry && entry?.kind === 'fs-query') {
329
387
  const verb = entry.operation === 'read' ? 'Read' : 'Write';
330
388
  return jsx(LandstripPermissionPrompt, {
331
389
  icon: '→',
332
- title: `${verb} ${formatPath(entry.path, directory)}`,
390
+ title: `${verb} ${formatPath(entry.path, entry.directory)}`,
333
391
  options: [
334
392
  { label: 'Allow once', value: 'once' },
335
393
  { label: 'Allow for session', value: 'session' },
@@ -339,9 +397,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
339
397
  ],
340
398
  onSelect: (choice: QueryChoice) => resolveFsQuery(entry, choice),
341
399
  onCancel: () => resolveFsQuery(entry, 'deny'),
400
+ onShow: () => notifyQuery(entry),
342
401
  });
343
402
  }
344
- if (entry?.kind === 'net-query') {
403
+ if (showEntry && entry?.kind === 'net-query') {
345
404
  const verb = entry.operation
346
405
  ? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
347
406
  : 'Network';
@@ -355,6 +414,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
355
414
  ],
356
415
  onSelect: (choice: NetworkQueryChoice) => resolveNetworkQuery(entry, choice),
357
416
  onCancel: () => resolveNetworkQuery(entry, 'deny'),
417
+ onShow: () => notifyQuery(entry),
358
418
  });
359
419
  }
360
420
  return api.ui.Prompt({
@@ -380,18 +440,14 @@ const tui: TuiPlugin = async (api, options, meta) => {
380
440
  if (resolved.has(entry.id)) return;
381
441
  const action = choice === 'deny' ? 'deny' : 'allow';
382
442
  const verb = entry.operation === 'read' ? 'Read' : 'Write';
383
- const directory = api.state.path.directory || process.cwd();
443
+ const directory = entry.directory;
384
444
  const scope = sessionScopeFor(entry.path, directory);
385
- const sessionPaths =
386
- entry.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
445
+ const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
446
+ const sessionPaths = entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
387
447
 
448
+ let responseAction: 'allow' | 'deny' = action;
388
449
  try {
389
450
  if (action === 'allow') {
390
- // Breadth-first: seed the session set with the broadest reasonable
391
- // ancestor so the still-running command stops prompting for sibling
392
- // files under the same tree. 'once' intentionally stays exact-path.
393
- if (choice !== 'once') sessionPaths.add(scope);
394
-
395
451
  if (choice === 'project' || choice === 'global') {
396
452
  const { globalPath, projectPath } = getConfigPaths(directory);
397
453
  const update = updateForPermission({
@@ -400,20 +456,22 @@ const tui: TuiPlugin = async (api, options, meta) => {
400
456
  });
401
457
  if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
402
458
  }
459
+ if (choice !== 'once') sessionPaths.add(scope);
403
460
  }
461
+ } catch {
462
+ responseAction = 'deny';
463
+ }
404
464
 
405
- respondQuery(entry.socket, entry.queryId, action);
465
+ try {
466
+ respondQuery(entry.socket, entry.queryId, responseAction);
406
467
  api.ui.toast({
407
468
  title: 'Sandbox',
408
469
  message:
409
- action === 'deny'
470
+ responseAction === 'deny'
410
471
  ? `${verb} denied: ${entry.path}`
411
472
  : `${verb} allowed (${choice}) under ${scope}`,
412
- variant: action === 'deny' ? 'warning' : 'success',
473
+ variant: responseAction === 'deny' ? 'warning' : 'success',
413
474
  });
414
- } catch {
415
- // Persisting failed — still release the held syscall by denying it.
416
- respondQuery(entry.socket, entry.queryId, 'deny');
417
475
  } finally {
418
476
  liveQueries.delete(entry);
419
477
  finishActive(entry.id);
@@ -423,20 +481,15 @@ const tui: TuiPlugin = async (api, options, meta) => {
423
481
  function showFsQuery(entry: FsQueryEntry): void {
424
482
  activeId = entry.id;
425
483
  setActiveEntry(entry);
426
-
427
- void api.attention.notify({
428
- title: `Sandbox ${entry.operation} blocked`,
429
- message: entry.path,
430
- sound: { name: 'permission' },
431
- notification: true,
432
- });
433
484
  }
434
485
  function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
435
486
  if (resolved.has(entry.id)) return;
436
487
  const action = choice === 'deny' ? 'deny' : 'allow';
437
488
 
438
489
  try {
439
- if (action === 'allow' && choice === 'session') sessionAllowedTargets.add(entry.target);
490
+ if (action === 'allow' && choice === 'session') {
491
+ sessionAllowancesFor(sessionAllowances, entry.sessionID).targets.add(entry.target);
492
+ }
440
493
 
441
494
  respondQuery(entry.socket, entry.queryId, action);
442
495
  api.ui.toast({
@@ -456,13 +509,6 @@ const tui: TuiPlugin = async (api, options, meta) => {
456
509
  function showNetworkQuery(entry: NetworkQueryEntry): void {
457
510
  activeId = entry.id;
458
511
  setActiveEntry(entry);
459
-
460
- void api.attention.notify({
461
- title: `Sandbox ${entry.operation} blocked`,
462
- message: entry.target,
463
- sound: { name: 'permission' },
464
- notification: true,
465
- });
466
512
  }
467
513
 
468
514
  // Query-response socket server (Linux-only — landstrip's socket protocol lives
@@ -481,6 +527,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
481
527
  const socketId = ++socketSeq;
482
528
  const seen = new Set<string>();
483
529
  let buffer = '';
530
+ let sourceSessionID: string | undefined;
531
+ let routeSessionID: string | undefined;
532
+ let sessionDirectory: string | undefined;
484
533
 
485
534
  socket.on('data', (chunk: string | Buffer) => {
486
535
  buffer += chunk.toString();
@@ -494,21 +543,37 @@ const tui: TuiPlugin = async (api, options, meta) => {
494
543
  const line = buffer.slice(0, newline);
495
544
  buffer = buffer.slice(newline + 1);
496
545
 
546
+ if (!sourceSessionID || !routeSessionID || !sessionDirectory) {
547
+ const hello = decodeTrapSessionHello(line);
548
+ const sourceSession = hello ? api.state.session.get(hello.sessionID) : undefined;
549
+ const rootSession = hello
550
+ ? rootSessionIDFor(hello.sessionID, (sessionID) => api.state.session.get(sessionID))
551
+ : undefined;
552
+ if (!hello || !sourceSession || !rootSession) {
553
+ socket.destroy();
554
+ return;
555
+ }
556
+ sourceSessionID = hello.sessionID;
557
+ routeSessionID = rootSession;
558
+ sessionDirectory = sourceSession.directory || baseDirectory;
559
+ continue;
560
+ }
561
+
562
+ const allowances = sessionAllowancesFor(sessionAllowances, routeSessionID);
497
563
  for (const trap of parseLandstripTraps(line)) {
498
- const directory = api.state.path.directory || process.cwd();
499
564
  if (trap.kind !== 'filesystem' && trap.kind !== 'network') continue;
500
565
  if (trap.state !== 'query') continue;
501
566
  if (seen.has(trap.query_id)) continue;
502
567
  seen.add(trap.query_id);
503
568
 
504
- if (!loadConfig(directory, optionOverrides).enabled) {
569
+ if (!loadConfig(sessionDirectory, optionOverrides).enabled) {
505
570
  respondQuery(socket, trap.query_id, 'allow');
506
571
  continue;
507
572
  }
508
573
 
509
574
  if (trap.kind === 'filesystem') {
510
575
  const sessionPaths =
511
- trap.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
576
+ trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
512
577
  if (sessionAllows(sessionPaths, trap.path)) {
513
578
  respondQuery(socket, trap.query_id, 'allow');
514
579
  continue;
@@ -521,11 +586,14 @@ const tui: TuiPlugin = async (api, options, meta) => {
521
586
  queryId: trap.query_id,
522
587
  operation: trap.operation,
523
588
  path: trap.path,
589
+ sessionID: routeSessionID,
590
+ sourceSessionID,
591
+ directory: sessionDirectory,
524
592
  };
525
593
  liveQueries.add(entry);
526
594
  enqueueEntry(entry);
527
595
  } else {
528
- if (sessionAllowedTargets.has(trap.target)) {
596
+ if (allowances.targets.has(trap.target)) {
529
597
  respondQuery(socket, trap.query_id, 'allow');
530
598
  continue;
531
599
  }
@@ -537,6 +605,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
537
605
  queryId: trap.query_id,
538
606
  operation: trap.operation,
539
607
  target: trap.target,
608
+ sessionID: routeSessionID,
609
+ sourceSessionID,
610
+ directory: sessionDirectory,
540
611
  };
541
612
  liveQueries.add(entry);
542
613
  enqueueEntry(entry);
@@ -579,6 +650,18 @@ const tui: TuiPlugin = async (api, options, meta) => {
579
650
  });
580
651
  }
581
652
 
653
+ const refreshPermissionPresentation = (): void => {
654
+ queueMicrotask(() => {
655
+ setPermissionRevision((revision) => revision + 1);
656
+ pump();
657
+ });
658
+ };
659
+ const unregisterPermissionAsked = api.event.on('permission.asked', refreshPermissionPresentation);
660
+ const unregisterPermissionReplied = api.event.on(
661
+ 'permission.replied',
662
+ refreshPermissionPresentation,
663
+ );
664
+
582
665
  // /sandbox shows the config and toggles the persisted `enabled` flag. The
583
666
  // server reads sandbox.json on every tool call, so the toggle takes effect on
584
667
  // the next command without any cross-process signalling.
@@ -683,11 +766,15 @@ const tui: TuiPlugin = async (api, options, meta) => {
683
766
  }
684
767
 
685
768
  api.lifecycle.onDispose(() => {
769
+ unregisterPermissionAsked();
770
+ unregisterPermissionReplied();
771
+ sessionAllowances.clear();
686
772
  // Deny any still-held queries so the sandboxed children don't hang, then
687
773
  // tear down the socket server and drop the discovery file.
688
774
  for (const entry of liveQueries) {
689
775
  respondQuery(entry.socket, entry.queryId, 'deny');
690
776
  liveQueries.delete(entry);
777
+ finishActive(entry.id);
691
778
  }
692
779
  for (const socket of sockets) socket.destroy();
693
780
  if (socketServer) {