opencode-landstrip 0.16.14 → 0.16.16

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 +140 -25
  2. package/package.json +1 -1
  3. package/shared.ts +46 -0
  4. package/tui.ts +2 -51
package/index.ts CHANGED
@@ -24,8 +24,8 @@ import {
24
24
  parseLandstripTraps,
25
25
  permissionPatterns,
26
26
  permissionType,
27
- readDiscoveryPort,
28
27
  sandboxSummary,
28
+ sessionScopeFor,
29
29
  } from './shared.js';
30
30
 
31
31
  interface LandstripPolicy {
@@ -45,6 +45,9 @@ interface BashSandboxState {
45
45
  policyDir: string;
46
46
  port: number | null;
47
47
  stop: (() => Promise<void>) | null;
48
+ trapServer: ReturnType<typeof createServer> | null;
49
+ trapServerPort: number | null;
50
+ trapLines: string[];
48
51
  }
49
52
 
50
53
  type SandboxPermissionKind = 'read' | 'write' | 'domain';
@@ -632,20 +635,83 @@ function shellArgs(shell: string, command: string): string[] {
632
635
  return [shell, '-lc', command];
633
636
  }
634
637
 
635
- // The query-response port is published by the TUI plugin and is Linux-only (the
636
- // socket protocol exists only in landstrip's seccomp broker).
637
- function socketQueryPort(baseDirectory: string): number | null {
638
- if (process.platform !== 'linux') return null;
639
- return readDiscoveryPort(baseDirectory);
640
- }
638
+ // Start a local TCP server that landstrip connects its trap fd to. Traps are
639
+ // handled in-process: query traps are answered immediately against the active
640
+ // config, and info traps are collected for post-execution error reporting.
641
+ function startTrapServer(
642
+ effectiveAllowRead: string[],
643
+ effectiveAllowWrite: string[],
644
+ denyRead: string[],
645
+ denyWrite: string[],
646
+ baseDirectory: string,
647
+ sessionAllowedReadPaths: Set<string>,
648
+ sessionAllowedWritePaths: Set<string>,
649
+ ): Promise<{ server: ReturnType<typeof createServer>; port: number; trapLines: string[] }> {
650
+ const trapLines: string[] = [];
651
+ const server = createServer((trapSocket) => {
652
+ let buffer = '';
653
+ trapSocket.on('data', (data: Buffer) => {
654
+ buffer += data.toString('utf8');
655
+ let nl = buffer.indexOf('\n');
656
+ while (nl !== -1) {
657
+ const line = buffer.slice(0, nl);
658
+ buffer = buffer.slice(nl + 1);
659
+ nl = buffer.indexOf('\n');
660
+ if (line.length === 0) continue;
661
+ let obj: Record<string, unknown> | null = null;
662
+ try {
663
+ const parsed: unknown = JSON.parse(line);
664
+ if (typeof parsed === 'object' && parsed !== null) {
665
+ obj = parsed as Record<string, unknown>;
666
+ }
667
+ } catch {
668
+ obj = null;
669
+ }
670
+ if (
671
+ obj &&
672
+ obj.state === 'query' &&
673
+ typeof obj.query_id === 'number' &&
674
+ (obj.operation === 'read' || obj.operation === 'write') &&
675
+ typeof obj.path === 'string'
676
+ ) {
677
+ const path = canonicalizePath(obj.path, baseDirectory);
678
+ const operation = obj.operation as 'read' | 'write';
679
+ // Per landstrip policy: a deny rule overrides allow only when more
680
+ // specific; a tie or more-specific allow carves the path back in.
681
+ const denyReadDepth = matchDepth(path, denyRead, baseDirectory);
682
+ const allowReadDepth = matchDepth(path, effectiveAllowRead, baseDirectory);
683
+ const denyWriteDepth = matchDepth(path, denyWrite, baseDirectory);
684
+ const allowWriteDepth = matchDepth(path, effectiveAllowWrite, baseDirectory);
685
+ const allowed =
686
+ operation === 'read'
687
+ ? !(denyReadDepth > allowReadDepth) && allowReadDepth >= 0
688
+ : !(denyWriteDepth > allowWriteDepth) && allowWriteDepth >= 0;
689
+ if (allowed) {
690
+ trapSocket.write(JSON.stringify({ query_id: obj.query_id, action: 'allow' }) + '\n');
691
+ } else {
692
+ // Auto-grant via session scope and allow so the command proceeds.
693
+ const scope = sessionScopeFor(path, baseDirectory);
694
+ if (operation === 'read') sessionAllowedReadPaths.add(scope);
695
+ else sessionAllowedWritePaths.add(scope);
696
+ trapSocket.write(JSON.stringify({ query_id: obj.query_id, action: 'allow' }) + '\n');
697
+ trapLines.push(line);
698
+ }
699
+ } else {
700
+ trapLines.push(line);
701
+ }
702
+ }
703
+ });
704
+ trapSocket.on('error', () => {});
705
+ });
641
706
 
642
- async function awaitQueryPort(baseDirectory: string): Promise<number | null> {
643
- for (let attempt = 0; attempt < 5; attempt++) {
644
- const port = socketQueryPort(baseDirectory);
645
- if (port !== null) return port;
646
- await new Promise((resolve) => setTimeout(resolve, 50));
647
- }
648
- return null;
707
+ return new Promise((resolve, reject) => {
708
+ server.on('error', reject);
709
+ server.listen(0, '127.0.0.1', () => {
710
+ server.removeListener('error', reject);
711
+ const address = server.address() as AddressInfo;
712
+ resolve({ server, port: address.port, trapLines });
713
+ });
714
+ });
649
715
  }
650
716
 
651
717
  function buildWrappedCommand(
@@ -768,6 +834,16 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
768
834
  const activeBash = new Map<string, BashSandboxState>();
769
835
  const notified = new Set<string>();
770
836
  const callAllowances = new Set<string>();
837
+ const sessionAllowedReadPaths = new Set<string>();
838
+ const sessionAllowedWritePaths = new Set<string>();
839
+
840
+ function getEffectiveAllowRead(config: SandboxConfig): string[] {
841
+ return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
842
+ }
843
+
844
+ function getEffectiveAllowWrite(config: SandboxConfig): string[] {
845
+ return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
846
+ }
771
847
  let enabledNotified = false;
772
848
  let sandboxDisabled = false;
773
849
  let configuredShell: string | undefined;
@@ -990,6 +1066,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
990
1066
 
991
1067
  activeBash.delete(callID);
992
1068
  if (state.stop) await state.stop().catch(() => undefined);
1069
+ if (state.trapServer) {
1070
+ await new Promise<void>((resolve) => {
1071
+ state.trapServer!.close(() => resolve());
1072
+ });
1073
+ }
993
1074
  rmSync(state.policyDir, { recursive: true, force: true });
994
1075
  }
995
1076
 
@@ -1034,6 +1115,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1034
1115
  const effectiveConfig = {
1035
1116
  ...config,
1036
1117
  network: { ...config.network },
1118
+ filesystem: {
1119
+ ...config.filesystem,
1120
+ allowRead: getEffectiveAllowRead(config),
1121
+ allowWrite: getEffectiveAllowWrite(config),
1122
+ },
1037
1123
  };
1038
1124
 
1039
1125
  if (!allowNetwork) {
@@ -1067,11 +1153,24 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1067
1153
  }
1068
1154
 
1069
1155
  const originalCommand = args.command as string;
1156
+
1157
+ // Start a local trap server so landstrip's query traps are answered
1158
+ // in-process instead of relying on the TUI plugin's discovery port.
1159
+ const trapServer = await startTrapServer(
1160
+ effectiveConfig.filesystem.allowRead,
1161
+ effectiveConfig.filesystem.allowWrite,
1162
+ effectiveConfig.filesystem.denyRead,
1163
+ effectiveConfig.filesystem.denyWrite,
1164
+ directory,
1165
+ sessionAllowedReadPaths,
1166
+ sessionAllowedWritePaths,
1167
+ );
1168
+
1070
1169
  const wrappedCommand = buildWrappedCommand(
1071
1170
  policy.path,
1072
1171
  configuredShell ?? process.env.SHELL ?? '/bin/sh',
1073
1172
  originalCommand,
1074
- await awaitQueryPort(directory),
1173
+ trapServer.port,
1075
1174
  );
1076
1175
 
1077
1176
  activeBash.set(callID, {
@@ -1080,6 +1179,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1080
1179
  policyDir: policy.dir,
1081
1180
  port: proxyPort,
1082
1181
  stop: proxy ? proxy.stop : null,
1182
+ trapServer: trapServer.server,
1183
+ trapServerPort: trapServer.port,
1184
+ trapLines: trapServer.trapLines,
1083
1185
  });
1084
1186
 
1085
1187
  args.command = wrappedCommand;
@@ -1108,8 +1210,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1108
1210
  const patterns = permissionPatterns(request);
1109
1211
 
1110
1212
  const decisions: SandboxPermissionDecision[] = [];
1111
- const effectiveAllowRead = config.filesystem.allowRead;
1112
- const effectiveAllowWrite = config.filesystem.allowWrite;
1213
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1214
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1113
1215
 
1114
1216
  if (permission === 'read') {
1115
1217
  for (const pattern of patterns) {
@@ -1158,8 +1260,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1158
1260
  const config = await activeConfig();
1159
1261
  if (!config) return;
1160
1262
 
1161
- const effectiveAllowRead = config.filesystem.allowRead;
1162
- const effectiveAllowWrite = config.filesystem.allowWrite;
1263
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1264
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1163
1265
 
1164
1266
  if (input.tool === 'bash') {
1165
1267
  await prepareBash(input.callID, output.args, config);
@@ -1223,9 +1325,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1223
1325
  }
1224
1326
 
1225
1327
  const outputText = output?.output ?? '';
1226
- // Query traps were already resolved interactively over the socket by the
1227
- // TUI plugin; only terminal (info) traps belong in the after-the-fact toast.
1228
- const errors = parseLandstripTraps(outputText).filter(
1328
+ // Query traps were already resolved in-process by the local trap server;
1329
+ // only terminal (info) traps and trap-server-collected lines belong in
1330
+ // the after-the-fact toast.
1331
+ const serverTrapOutput = state.trapLines.join('\n');
1332
+ const combinedOutput = serverTrapOutput ? outputText + '\n' + serverTrapOutput : outputText;
1333
+ const errors = parseLandstripTraps(combinedOutput).filter(
1229
1334
  (trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
1230
1335
  );
1231
1336
  if (errors.length > 0) {
@@ -1250,9 +1355,19 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1250
1355
 
1251
1356
  const blockedPath = extractBlockedPath(outputText, directory, state.originalCommand);
1252
1357
  if (blockedPath) {
1358
+ let blockedOperation: 'read' | 'write' = 'read';
1359
+ for (const trap of errors) {
1360
+ if (trap.kind === 'filesystem') {
1361
+ blockedOperation = trap.operation;
1362
+ break;
1363
+ }
1364
+ }
1365
+ const scope = sessionScopeFor(blockedPath, directory);
1366
+ if (blockedOperation === 'read') sessionAllowedReadPaths.add(scope);
1367
+ else sessionAllowedWritePaths.add(scope);
1253
1368
  await notifyOnce(
1254
1369
  `blocked:${blockedPath}`,
1255
- `Sandbox blocked access to "${blockedPath}". Approve the related OpenCode permission prompt and retry if needed.`,
1370
+ `Sandbox blocked ${blockedOperation} to "${blockedPath}". Added "${scope}" to session allowlist; retry the command.`,
1256
1371
  'warning',
1257
1372
  );
1258
1373
  }
@@ -1346,8 +1461,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1346
1461
  const config = await activeConfig();
1347
1462
  if (!config) return;
1348
1463
 
1349
- const effectiveAllowRead = config.filesystem.allowRead;
1350
- const effectiveAllowWrite = config.filesystem.allowWrite;
1464
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1465
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1351
1466
 
1352
1467
  for (const path of extractCandidatePaths(shellCommand)) {
1353
1468
  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.16.14",
3
+ "version": "0.16.16",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
package/shared.ts CHANGED
@@ -47,6 +47,52 @@ const LANDSTRIP_PACKAGE_NAMES = new Set([
47
47
  '@landstrip/landstrip-win32-x64',
48
48
  ]);
49
49
 
50
+ // Breadth-first filesystem approval: a held read/write under a directory tree
51
+ // is approved for the broadest reasonable ancestor (e.g. `~/.cargo`, not each
52
+ // subcrate file), so a single approval covers sibling files under the same tree.
53
+ export function pathUnderDirectory(filePath: string, dir: string): boolean {
54
+ if (filePath === dir) return true;
55
+ const sep = dir.endsWith('/') ? '' : '/';
56
+ return filePath.startsWith(dir + sep);
57
+ }
58
+
59
+ export function sessionAllows(prefixes: Set<string>, filePath: string): boolean {
60
+ for (const prefix of prefixes) {
61
+ if (pathUnderDirectory(filePath, prefix)) return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ // The broadest ancestor worth approving in one click: the immediate child of
67
+ // `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
68
+ // for paths under it, otherwise the containing directory. When the file sits
69
+ // directly on a boundary (so the only ancestor is `$HOME` itself, which would
70
+ // over-broaden), fall back to the exact file so nothing widens silently.
71
+ export function sessionScopeFor(filePath: string, baseDirectory: string): string {
72
+ const dir = dirname(filePath);
73
+ const home = homedir();
74
+ const boundaries = new Set<string>();
75
+ if (home) boundaries.add(home);
76
+ try {
77
+ const realHome = realpathSync.native(home);
78
+ if (realHome) boundaries.add(realHome);
79
+ } catch {
80
+ // $HOME not resolvable — fall back to the raw value only.
81
+ }
82
+
83
+ for (const boundary of boundaries) {
84
+ if (pathUnderDirectory(dir, boundary)) {
85
+ const rest = dir.slice(boundary.length).replace(/^\/+/, '');
86
+ const first = rest.split('/')[0];
87
+ if (!first) return filePath;
88
+ return boundary.endsWith('/') ? boundary + first : `${boundary}/${first}`;
89
+ }
90
+ }
91
+
92
+ if (pathUnderDirectory(dir, baseDirectory)) return baseDirectory;
93
+ return dir;
94
+ }
95
+
50
96
  export function isRecord(value: unknown): value is Record<string, unknown> {
51
97
  return typeof value === 'object' && value !== null && !Array.isArray(value);
52
98
  }
package/tui.ts CHANGED
@@ -2,10 +2,7 @@
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
4
  import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
5
- import { realpathSync } from 'node:fs';
6
5
  import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
7
- import { homedir } from 'node:os';
8
- import { dirname } from 'node:path';
9
6
 
10
7
  import {
11
8
  getConfigPaths,
@@ -15,7 +12,9 @@ import {
15
12
  permissionLabel,
16
13
  permissionResource,
17
14
  removeDiscoveryFile,
15
+ sessionAllows,
18
16
  sandboxSummary,
17
+ sessionScopeFor,
19
18
  updateForPermission,
20
19
  writeConfigFile,
21
20
  writeDiscoveryPort,
@@ -67,54 +66,6 @@ function permissionDetail(permission: PendingPermission): string {
67
66
  return resource && !label.includes(resource) ? `${label}: ${resource}` : label;
68
67
  }
69
68
 
70
- // Breadth-first filesystem approval: a held read/write under a directory tree
71
- // is approved for the broadest reasonable ancestor (e.g. `~/.cargo`, not each
72
- // subcrate file), so a single scan does not spawn one dialog per file. The
73
- // session set stores directory prefixes and is matched with separator-safe
74
- // prefix logic so a sibling file under an approved scope is auto-allowed.
75
- function pathUnderDirectory(filePath: string, dir: string): boolean {
76
- if (filePath === dir) return true;
77
- const sep = dir.endsWith('/') ? '' : '/';
78
- return filePath.startsWith(dir + sep);
79
- }
80
-
81
- function sessionAllows(prefixes: Set<string>, filePath: string): boolean {
82
- for (const prefix of prefixes) {
83
- if (pathUnderDirectory(filePath, prefix)) return true;
84
- }
85
- return false;
86
- }
87
-
88
- // The broadest ancestor worth approving in one click: the immediate child of
89
- // `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
90
- // for paths under it, otherwise the containing directory. When the file sits
91
- // directly on a boundary (so the only ancestor is `$HOME` itself, which would
92
- // over-broaden), fall back to the exact file so nothing widens silently.
93
- function sessionScopeFor(filePath: string, baseDirectory: string): string {
94
- const dir = dirname(filePath);
95
- const home = homedir();
96
- const boundaries = new Set<string>();
97
- if (home) boundaries.add(home);
98
- try {
99
- const realHome = realpathSync.native(home);
100
- if (realHome) boundaries.add(realHome);
101
- } catch {
102
- // $HOME not resolvable — fall back to the raw value only.
103
- }
104
-
105
- for (const boundary of boundaries) {
106
- if (pathUnderDirectory(dir, boundary)) {
107
- const rest = dir.slice(boundary.length).replace(/^\/+/, '');
108
- const first = rest.split('/')[0];
109
- if (!first) return filePath;
110
- return boundary.endsWith('/') ? boundary + first : `${boundary}/${first}`;
111
- }
112
- }
113
-
114
- if (pathUnderDirectory(dir, baseDirectory)) return baseDirectory;
115
- return dir;
116
- }
117
-
118
69
  const tui: TuiPlugin = async (api, options, meta) => {
119
70
  const optionOverrides = normalizeOptions(options);
120
71