opencode-landstrip 0.16.13 → 0.16.15

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 +33 -7
  2. package/package.json +1 -1
  3. package/shared.ts +64 -32
  4. package/tui.ts +2 -51
package/index.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  permissionType,
27
27
  readDiscoveryPort,
28
28
  sandboxSummary,
29
+ sessionScopeFor,
29
30
  } from './shared.js';
30
31
 
31
32
  interface LandstripPolicy {
@@ -768,6 +769,16 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
768
769
  const activeBash = new Map<string, BashSandboxState>();
769
770
  const notified = new Set<string>();
770
771
  const callAllowances = new Set<string>();
772
+ const sessionAllowedReadPaths = new Set<string>();
773
+ const sessionAllowedWritePaths = new Set<string>();
774
+
775
+ function getEffectiveAllowRead(config: SandboxConfig): string[] {
776
+ return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
777
+ }
778
+
779
+ function getEffectiveAllowWrite(config: SandboxConfig): string[] {
780
+ return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
781
+ }
771
782
  let enabledNotified = false;
772
783
  let sandboxDisabled = false;
773
784
  let configuredShell: string | undefined;
@@ -1034,6 +1045,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1034
1045
  const effectiveConfig = {
1035
1046
  ...config,
1036
1047
  network: { ...config.network },
1048
+ filesystem: {
1049
+ ...config.filesystem,
1050
+ allowRead: getEffectiveAllowRead(config),
1051
+ allowWrite: getEffectiveAllowWrite(config),
1052
+ },
1037
1053
  };
1038
1054
 
1039
1055
  if (!allowNetwork) {
@@ -1108,8 +1124,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1108
1124
  const patterns = permissionPatterns(request);
1109
1125
 
1110
1126
  const decisions: SandboxPermissionDecision[] = [];
1111
- const effectiveAllowRead = config.filesystem.allowRead;
1112
- const effectiveAllowWrite = config.filesystem.allowWrite;
1127
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1128
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1113
1129
 
1114
1130
  if (permission === 'read') {
1115
1131
  for (const pattern of patterns) {
@@ -1158,8 +1174,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1158
1174
  const config = await activeConfig();
1159
1175
  if (!config) return;
1160
1176
 
1161
- const effectiveAllowRead = config.filesystem.allowRead;
1162
- const effectiveAllowWrite = config.filesystem.allowWrite;
1177
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1178
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1163
1179
 
1164
1180
  if (input.tool === 'bash') {
1165
1181
  await prepareBash(input.callID, output.args, config);
@@ -1250,9 +1266,19 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1250
1266
 
1251
1267
  const blockedPath = extractBlockedPath(outputText, directory, state.originalCommand);
1252
1268
  if (blockedPath) {
1269
+ let blockedOperation: 'read' | 'write' = 'read';
1270
+ for (const trap of errors) {
1271
+ if (trap.kind === 'filesystem') {
1272
+ blockedOperation = trap.operation;
1273
+ break;
1274
+ }
1275
+ }
1276
+ const scope = sessionScopeFor(blockedPath, directory);
1277
+ if (blockedOperation === 'read') sessionAllowedReadPaths.add(scope);
1278
+ else sessionAllowedWritePaths.add(scope);
1253
1279
  await notifyOnce(
1254
1280
  `blocked:${blockedPath}`,
1255
- `Sandbox blocked access to "${blockedPath}". Approve the related OpenCode permission prompt and retry if needed.`,
1281
+ `Sandbox blocked ${blockedOperation} to "${blockedPath}". Added "${scope}" to session allowlist; retry the command.`,
1256
1282
  'warning',
1257
1283
  );
1258
1284
  }
@@ -1346,8 +1372,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1346
1372
  const config = await activeConfig();
1347
1373
  if (!config) return;
1348
1374
 
1349
- const effectiveAllowRead = config.filesystem.allowRead;
1350
- const effectiveAllowWrite = config.filesystem.allowWrite;
1375
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1376
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1351
1377
 
1352
1378
  for (const path of extractCandidatePaths(shellCommand)) {
1353
1379
  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.13",
3
+ "version": "0.16.15",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
package/shared.ts CHANGED
@@ -6,6 +6,7 @@ import { binaryPath } from '@landstrip/landstrip';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
8
8
  import { homedir, tmpdir } from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
9
10
  import { dirname, join } from 'node:path';
10
11
 
11
12
  export interface SandboxFilesystemConfig {
@@ -36,30 +37,7 @@ export interface SandboxConfigOverrides {
36
37
  filesystem?: Partial<SandboxFilesystemConfig>;
37
38
  }
38
39
 
39
- export const DEFAULT_CONFIG: SandboxConfig = {
40
- enabled: true,
41
- network: {
42
- allowNetwork: false,
43
- allowLocalBinding: false,
44
- allowAllUnixSockets: false,
45
- allowUnixSockets: [],
46
- allowedDomains: [],
47
- deniedDomains: [],
48
- },
49
- filesystem: {
50
- denyRead: ['/Users', '/home'],
51
- allowRead: ['.', '~/.gitconfig', '~/.config/git/config', '/dev/null'],
52
- allowWrite: ['.', '/dev/null'],
53
- denyWrite: [
54
- '**/.env',
55
- '**/.env.*',
56
- '**/*.pem',
57
- '**/*.key',
58
- '.opencode/sandbox.json',
59
- '~/.config/opencode/sandbox.json',
60
- ],
61
- },
62
- };
40
+ const packageDir = dirname(fileURLToPath(import.meta.url));
63
41
 
64
42
  const LANDSTRIP_PACKAGE_NAMES = new Set([
65
43
  '@landstrip/landstrip',
@@ -69,6 +47,52 @@ const LANDSTRIP_PACKAGE_NAMES = new Set([
69
47
  '@landstrip/landstrip-win32-x64',
70
48
  ]);
71
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
+
72
96
  export function isRecord(value: unknown): value is Record<string, unknown> {
73
97
  return typeof value === 'object' && value !== null && !Array.isArray(value);
74
98
  }
@@ -195,13 +219,18 @@ export function loadConfig(
195
219
  optionOverrides: SandboxConfigOverrides,
196
220
  ): SandboxConfig {
197
221
  const { globalPath, projectPath } = getConfigPaths(baseDirectory);
198
- return deepMerge(
199
- deepMerge(
200
- deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath) ?? {}),
201
- readConfigFile(projectPath) ?? {},
202
- ),
203
- optionOverrides,
204
- );
222
+ const templatePath = join(packageDir, 'sandbox.json');
223
+
224
+ if (!existsSync(globalPath)) {
225
+ mkdirSync(dirname(globalPath), { recursive: true });
226
+ writeFileSync(globalPath, readFileSync(templatePath, 'utf-8'), 'utf-8');
227
+ }
228
+
229
+ const templateConfig: SandboxConfig = JSON.parse(readFileSync(templatePath, 'utf-8'));
230
+ const globalOverrides = readConfigFile(globalPath) ?? {};
231
+ const baseConfig = deepMerge(templateConfig, globalOverrides);
232
+
233
+ return deepMerge(deepMerge(baseConfig, readConfigFile(projectPath) ?? {}), optionOverrides);
205
234
  }
206
235
 
207
236
  export function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
@@ -210,7 +239,10 @@ export function writeConfigFile(configPath: string, update: SandboxConfigOverrid
210
239
  throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
211
240
  }
212
241
 
213
- const next = deepMerge(deepMerge(DEFAULT_CONFIG, current), update);
242
+ const templateConfig: SandboxConfig = JSON.parse(
243
+ readFileSync(join(packageDir, 'sandbox.json'), 'utf-8'),
244
+ );
245
+ const next = deepMerge(deepMerge(templateConfig, current), update);
214
246
 
215
247
  mkdirSync(dirname(configPath), { recursive: true });
216
248
  writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
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