opencode-landstrip 0.16.11 → 0.16.13

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.
package/README.md CHANGED
@@ -1,9 +1,31 @@
1
1
  # opencode-landstrip
2
2
 
3
- Landlock-based sandboxing for [opencode](https://opencode.ai/) using
4
- [`landstrip`](https://github.com/landstrip/landstrip).
3
+ `opencode-landstrip` is a plugin for [OpenCode](https://opencode.ai/) providing
4
+ a sandbox defined with a policy compatible with Anthropic's JSON format. It uses
5
+ [`landstrip`](https://github.com/landstrip/landstrip) to implement the sandbox.
5
6
 
6
- ## Install
7
+ `opencode-landstrip` has a default policy [sandbox.json](./sandbox.json), and
8
+ allows the define either or both global or project specific policies.
9
+
10
+ ## Installing the plugin
11
+
12
+ ### Automatic install
13
+
14
+ Project specific install:
15
+
16
+ ```
17
+ opencode plugin install opencode-landstrip
18
+ ```
19
+
20
+ Global install:
21
+
22
+ ```
23
+ opencode plugin install opencode-landstrip --global
24
+ ```
25
+
26
+ ### Manual install
27
+
28
+ These changes are applied to OpenCode's configuration directories
7
29
 
8
30
  Add the plugin to `opencode.json`:
9
31
 
@@ -14,8 +36,7 @@ Add the plugin to `opencode.json`:
14
36
  }
15
37
  ```
16
38
 
17
- Add the TUI entrypoint to `tui.json` if you install or configure the plugin
18
- manually:
39
+ Add TUI entry point to `tui.json`:
19
40
 
20
41
  ```json
21
42
  {
@@ -24,59 +45,31 @@ manually:
24
45
  }
25
46
  ```
26
47
 
27
- `opencode plugin install opencode-landstrip` configures both entrypoints.
28
-
29
- This installs `opencode-landstrip` and its `@landstrip/landstrip` dependency, which
30
- includes platform-specific native binaries for Linux, macOS, and Windows.
31
-
32
- Requires OpenCode `1.17.7` or newer.
33
-
34
- On unsupported platforms the plugin loads but leaves sandboxing disabled.
35
-
36
- ## Configure
37
-
38
- Create `.opencode/sandbox.json` in a project or
39
- `~/.config/opencode/sandbox.json` globally. Project config takes precedence and
40
- array fields are merged with global/default values.
48
+ The plugin can be later on disabled as follows:
41
49
 
42
- See [`sandbox.json`](./sandbox.json) for a starter config.
50
+ ```json
51
+ {
52
+ "$schema": "https://opencode.ai/config.json",
53
+ "plugin": [["opencode-landstrip", { "enabled": false }]]
54
+ }
55
+ ```
43
56
 
44
57
  ## Behavior
45
58
 
46
- The plugin wraps opencode's AI `bash` tool in `landstrip`, routes proxy-aware
47
- network traffic through an allowlist proxy, and blocks read/write tool access
48
- outside configured filesystem allowlists. The default policy is strict: network
49
- access is off unless domains are allowed, reads are limited to the project,
50
- `~/.gitconfig`, and `/dev/null`, and writes are limited to the project and
51
- `/dev/null`.
52
-
53
- Run `/sandbox` in the TUI to inspect the active sandbox configuration. A compact
54
- status badge in the prompt area shows whether the sandbox is active and whether
55
- network is proxied or open.
56
-
57
- When OpenCode asks for a sandboxed permission, the TUI plugin plays the host's
58
- permission sound and desktop notification, then opens a single dialog with
59
- choices to allow once, allow for the session, persist for the project, persist
60
- globally, or reject. The dialog shows the exact path or domain being approved.
59
+ When OpenCode asks for a sandboxed permission, the plugin emits a host
60
+ notification. After that the plugin opens a dialog with the choices to allow
61
+ once, allow for the session, persist for the project, persist globally, or
62
+ reject. The dialog shows the exact path or domain being approved.
63
+
61
64
  Project approvals are written to `.opencode/sandbox.json`; global approvals are
62
- written to `~/.config/opencode/sandbox.json`.
65
+ written to `~/.config/opencode/sandbox.json`. When the global configuration is
66
+ initially written it acquires the copy of the default sandbox configuration.
63
67
 
64
68
  OpenCode's current plugin API allows wrapping AI `bash` tool calls, but does not
65
69
  allow a plugin to replace manually typed shell-mode commands with a landstrip
66
70
  wrapper. Those commands can still receive the proxy environment from OpenCode,
67
71
  but they are not process-sandboxed by this plugin.
68
72
 
69
- ## Disable
70
-
71
- Set `enabled` to `false` in `sandbox.json`, or pass plugin options:
72
-
73
- ```json
74
- {
75
- "$schema": "https://opencode.ai/config.json",
76
- "plugin": [["opencode-landstrip", { "enabled": false }]]
77
- }
78
- ```
79
-
80
73
  ## License
81
74
 
82
75
  `opencode-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.16.11",
3
+ "version": "0.16.13",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
package/sandbox.json CHANGED
@@ -12,6 +12,13 @@
12
12
  "denyRead": ["/Users", "/home"],
13
13
  "allowRead": [".", "~/.gitconfig", "~/.config/git/config", "/dev/null"],
14
14
  "allowWrite": [".", "/dev/null"],
15
- "denyWrite": ["**/.env", "**/.env.*", "**/*.pem", "**/*.key"]
15
+ "denyWrite": [
16
+ "**/.env",
17
+ "**/.env.*",
18
+ "**/*.pem",
19
+ "**/*.key",
20
+ ".opencode/sandbox.json",
21
+ "~/.config/opencode/sandbox.json"
22
+ ]
16
23
  }
17
24
  }
package/shared.ts CHANGED
@@ -50,7 +50,14 @@ export const DEFAULT_CONFIG: SandboxConfig = {
50
50
  denyRead: ['/Users', '/home'],
51
51
  allowRead: ['.', '~/.gitconfig', '~/.config/git/config', '/dev/null'],
52
52
  allowWrite: ['.', '/dev/null'],
53
- denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
53
+ denyWrite: [
54
+ '**/.env',
55
+ '**/.env.*',
56
+ '**/*.pem',
57
+ '**/*.key',
58
+ '.opencode/sandbox.json',
59
+ '~/.config/opencode/sandbox.json',
60
+ ],
54
61
  },
55
62
  };
56
63
 
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
  }