opencode-landstrip 0.16.11 → 0.16.12

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 (2) hide show
  1. package/package.json +1 -1
  2. package/tui.ts +72 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.16.11",
3
+ "version": "0.16.12",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
package/tui.ts CHANGED
@@ -2,7 +2,10 @@
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';
5
6
  import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
7
+ import { homedir } from 'node:os';
8
+ import { dirname } from 'node:path';
6
9
 
7
10
  import {
8
11
  getConfigPaths,
@@ -64,6 +67,54 @@ function permissionDetail(permission: PendingPermission): string {
64
67
  return resource && !label.includes(resource) ? `${label}: ${resource}` : label;
65
68
  }
66
69
 
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
+
67
118
  const tui: TuiPlugin = async (api, options, meta) => {
68
119
  const optionOverrides = normalizeOptions(options);
69
120
 
@@ -237,19 +288,23 @@ const tui: TuiPlugin = async (api, options, meta) => {
237
288
  if (resolved.has(entry.id)) return;
238
289
  const action = choice === 'deny' ? 'deny' : 'allow';
239
290
  const verb = entry.operation === 'read' ? 'Read' : 'Write';
291
+ const directory = api.state.path.directory || process.cwd();
292
+ const scope = sessionScopeFor(entry.path, directory);
293
+ const sessionPaths =
294
+ entry.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
240
295
 
241
296
  try {
242
297
  if (action === 'allow') {
243
- if (choice === 'session') {
244
- const sessionPaths =
245
- entry.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
246
- sessionPaths.add(entry.path);
247
- } else if (choice === 'project' || choice === 'global') {
248
- const directory = api.state.path.directory || process.cwd();
298
+ // Breadth-first: seed the session set with the broadest reasonable
299
+ // ancestor so the still-running command stops prompting for sibling
300
+ // files under the same tree. 'once' intentionally stays exact-path.
301
+ if (choice !== 'once') sessionPaths.add(scope);
302
+
303
+ if (choice === 'project' || choice === 'global') {
249
304
  const { globalPath, projectPath } = getConfigPaths(directory);
250
305
  const update = updateForPermission({
251
306
  permission: entry.operation,
252
- metadata: { filepath: entry.path },
307
+ metadata: { filepath: scope },
253
308
  });
254
309
  if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
255
310
  }
@@ -259,7 +314,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
259
314
  api.ui.toast({
260
315
  title: 'Sandbox',
261
316
  message:
262
- action === 'deny' ? `${verb} denied: ${entry.path}` : `${verb} allowed (${choice})`,
317
+ action === 'deny'
318
+ ? `${verb} denied: ${entry.path}`
319
+ : `${verb} allowed (${choice}) under ${scope}`,
263
320
  variant: action === 'deny' ? 'warning' : 'success',
264
321
  });
265
322
  } catch {
@@ -276,6 +333,8 @@ const tui: TuiPlugin = async (api, options, meta) => {
276
333
  const verb = entry.operation === 'read' ? 'Read' : 'Write';
277
334
  const noun = entry.operation;
278
335
  const listName = entry.operation === 'read' ? 'allowRead' : 'allowWrite';
336
+ const directory = api.state.path.directory || process.cwd();
337
+ const scope = sessionScopeFor(entry.path, directory);
279
338
 
280
339
  void api.attention.notify({
281
340
  title: `Sandbox ${noun} blocked`,
@@ -299,25 +358,25 @@ const tui: TuiPlugin = async (api, options, meta) => {
299
358
  title: 'Allow once',
300
359
  value: 'once',
301
360
  category: `This ${noun}`,
302
- description: `Permit this ${noun} and continue`,
361
+ description: `Permit only this ${noun}`,
303
362
  },
304
363
  {
305
364
  title: 'Allow for session',
306
365
  value: 'session',
307
366
  category: `This ${noun}`,
308
- description: `Permit ${noun}s to this path for the rest of this session`,
367
+ description: `Permit ${noun}s under ${scope} for this session`,
309
368
  },
310
369
  {
311
370
  title: 'Allow for project',
312
371
  value: 'project',
313
372
  category: 'Persist to sandbox.json',
314
- description: `Add to .opencode/sandbox.json ${listName} and permit`,
373
+ description: `Add ${scope} to .opencode/sandbox.json ${listName}`,
315
374
  },
316
375
  {
317
376
  title: 'Allow globally',
318
377
  value: 'global',
319
378
  category: 'Persist to sandbox.json',
320
- description: `Add to ~/.config/opencode/sandbox.json ${listName} and permit`,
379
+ description: `Add ${scope} to ~/.config/opencode/sandbox.json ${listName}`,
321
380
  },
322
381
  {
323
382
  title: 'Deny',
@@ -383,7 +442,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
383
442
 
384
443
  const sessionPaths =
385
444
  trap.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
386
- if (sessionPaths.has(trap.path)) {
445
+ if (sessionAllows(sessionPaths, trap.path)) {
387
446
  respondFsQuery(socket, trap.queryId, 'allow');
388
447
  continue;
389
448
  }