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 +40 -47
- package/package.json +1 -1
- package/sandbox.json +8 -1
- package/shared.ts +8 -1
- package/tui.ts +72 -13
package/README.md
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
# opencode-landstrip
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
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": [
|
|
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: [
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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:
|
|
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'
|
|
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}
|
|
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
|
|
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}
|
|
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}
|
|
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
|
|
445
|
+
if (sessionAllows(sessionPaths, trap.path)) {
|
|
387
446
|
respondFsQuery(socket, trap.queryId, 'allow');
|
|
388
447
|
continue;
|
|
389
448
|
}
|