opencode-landstrip 0.3.11 → 0.3.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 +28 -10
- package/index.ts +151 -218
- package/package.json +8 -7
- package/sandbox.json +7 -47
- package/shared.ts +318 -0
- package/tui.ts +265 -200
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ manually:
|
|
|
20
20
|
```json
|
|
21
21
|
{
|
|
22
22
|
"$schema": "https://opencode.ai/config.json",
|
|
23
|
-
"plugin": ["opencode-landstrip"]
|
|
23
|
+
"plugin": ["opencode-landstrip/tui"]
|
|
24
24
|
}
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -29,12 +29,15 @@ manually:
|
|
|
29
29
|
This installs `opencode-landstrip` and its `@jarkkojs/landstrip` dependency, which
|
|
30
30
|
includes platform-specific native binaries for Linux, macOS, and Windows.
|
|
31
31
|
|
|
32
|
+
Requires OpenCode `1.17.7` or newer.
|
|
33
|
+
|
|
32
34
|
On unsupported platforms the plugin loads but leaves sandboxing disabled.
|
|
33
35
|
|
|
34
36
|
## Configure
|
|
35
37
|
|
|
36
38
|
Create `.opencode/sandbox.json` in a project or
|
|
37
|
-
`~/.config/opencode/sandbox.json` globally. Project config takes precedence
|
|
39
|
+
`~/.config/opencode/sandbox.json` globally. Project config takes precedence and
|
|
40
|
+
array fields are merged with global/default values.
|
|
38
41
|
|
|
39
42
|
See [`sandbox.json`](./sandbox.json) for a starter config.
|
|
40
43
|
|
|
@@ -42,14 +45,26 @@ See [`sandbox.json`](./sandbox.json) for a starter config.
|
|
|
42
45
|
|
|
43
46
|
The plugin wraps opencode's AI `bash` tool in `landstrip`, routes proxy-aware
|
|
44
47
|
network traffic through an allowlist proxy, and blocks read/write tool access
|
|
45
|
-
outside configured filesystem allowlists.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
status
|
|
52
|
-
|
|
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.
|
|
61
|
+
Project approvals are written to `.opencode/sandbox.json`; global approvals are
|
|
62
|
+
written to `~/.config/opencode/sandbox.json`.
|
|
63
|
+
|
|
64
|
+
OpenCode's current plugin API allows wrapping AI `bash` tool calls, but does not
|
|
65
|
+
allow a plugin to replace manually typed shell-mode commands with a landstrip
|
|
66
|
+
wrapper. Those commands can still receive the proxy environment from OpenCode,
|
|
67
|
+
but they are not process-sandboxed by this plugin.
|
|
53
68
|
|
|
54
69
|
## Disable
|
|
55
70
|
|
|
@@ -66,3 +81,6 @@ Set `enabled` to `false` in `sandbox.json`, or pass plugin options:
|
|
|
66
81
|
|
|
67
82
|
`opencode-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
|
|
68
83
|
information.
|
|
84
|
+
|
|
85
|
+
The bundled `@jarkkojs/landstrip` package is licensed separately as
|
|
86
|
+
`Apache-2.0 AND LGPL-2.1-or-later`.
|
package/index.ts
CHANGED
|
@@ -3,43 +3,23 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Hooks, Plugin, PluginInput, PluginOptions } from '@opencode-ai/plugin';
|
|
5
5
|
|
|
6
|
-
import { binaryPath } from '@jarkkojs/landstrip';
|
|
7
|
-
|
|
8
6
|
import { spawnSync } from 'node:child_process';
|
|
9
|
-
import {
|
|
10
|
-
existsSync,
|
|
11
|
-
mkdtempSync,
|
|
12
|
-
readFileSync,
|
|
13
|
-
realpathSync,
|
|
14
|
-
rmSync,
|
|
15
|
-
writeFileSync,
|
|
16
|
-
} from 'node:fs';
|
|
7
|
+
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
8
|
import { type AddressInfo, connect as connectNet, createServer, type Socket } from 'node:net';
|
|
18
9
|
import { homedir, tmpdir } from 'node:os';
|
|
19
10
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
20
11
|
import { URL } from 'node:url';
|
|
21
12
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
allowAllUnixSockets: boolean;
|
|
33
|
-
allowUnixSockets: string[];
|
|
34
|
-
allowedDomains: string[];
|
|
35
|
-
deniedDomains: string[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface SandboxConfig {
|
|
39
|
-
enabled: boolean;
|
|
40
|
-
network: SandboxNetworkConfig;
|
|
41
|
-
filesystem: SandboxFilesystemConfig;
|
|
42
|
-
}
|
|
13
|
+
import {
|
|
14
|
+
type SandboxConfig,
|
|
15
|
+
type SandboxFilesystemConfig,
|
|
16
|
+
extractDomainsFromCommand,
|
|
17
|
+
getConfigPaths,
|
|
18
|
+
isRecord,
|
|
19
|
+
landstripBinaryPath,
|
|
20
|
+
loadConfig,
|
|
21
|
+
normalizeOptions,
|
|
22
|
+
} from './shared.js';
|
|
43
23
|
|
|
44
24
|
interface LandstripPolicy {
|
|
45
25
|
network: {
|
|
@@ -53,17 +33,12 @@ interface LandstripPolicy {
|
|
|
53
33
|
}
|
|
54
34
|
|
|
55
35
|
interface LandstripErrorResponse {
|
|
56
|
-
reason: 'Other' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
|
|
36
|
+
reason: 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
|
|
57
37
|
file?: string;
|
|
38
|
+
operation?: 'read' | 'write';
|
|
58
39
|
program?: string;
|
|
59
40
|
type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
|
|
60
|
-
source
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface SandboxConfigOverrides {
|
|
64
|
-
enabled?: boolean;
|
|
65
|
-
network?: Partial<SandboxNetworkConfig>;
|
|
66
|
-
filesystem?: Partial<SandboxFilesystemConfig>;
|
|
41
|
+
source?: string;
|
|
67
42
|
}
|
|
68
43
|
|
|
69
44
|
interface BashSandboxState {
|
|
@@ -85,157 +60,40 @@ interface SandboxPermissionDecision {
|
|
|
85
60
|
|
|
86
61
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
87
62
|
|
|
88
|
-
const LANDSTRIP_VERSION = [0, 11,
|
|
63
|
+
const LANDSTRIP_VERSION = [0, 11, 9] as const;
|
|
64
|
+
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
65
|
+
const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorResponse['reason']>([
|
|
66
|
+
'Other',
|
|
67
|
+
'AccessDenied',
|
|
68
|
+
'LaunchFailed',
|
|
69
|
+
'SetupFailed',
|
|
70
|
+
'Usage',
|
|
71
|
+
]);
|
|
72
|
+
const LANDSTRIP_OPERATIONS = new Set<NonNullable<LandstripErrorResponse['operation']>>([
|
|
73
|
+
'read',
|
|
74
|
+
'write',
|
|
75
|
+
]);
|
|
76
|
+
const LANDSTRIP_ERROR_TYPES = new Set<NonNullable<LandstripErrorResponse['type']>>([
|
|
77
|
+
'filesystem',
|
|
78
|
+
'network',
|
|
79
|
+
'platform',
|
|
80
|
+
'launch',
|
|
81
|
+
'encoding',
|
|
82
|
+
]);
|
|
89
83
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
90
84
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
network: {
|
|
94
|
-
allowNetwork: false,
|
|
95
|
-
allowLocalBinding: false,
|
|
96
|
-
allowAllUnixSockets: false,
|
|
97
|
-
allowUnixSockets: [],
|
|
98
|
-
allowedDomains: [
|
|
99
|
-
'npmjs.org',
|
|
100
|
-
'*.npmjs.org',
|
|
101
|
-
'registry.npmjs.org',
|
|
102
|
-
'registry.yarnpkg.com',
|
|
103
|
-
'pypi.org',
|
|
104
|
-
'*.pypi.org',
|
|
105
|
-
'github.com',
|
|
106
|
-
'*.github.com',
|
|
107
|
-
'api.github.com',
|
|
108
|
-
'raw.githubusercontent.com',
|
|
109
|
-
'crates.io',
|
|
110
|
-
'*.crates.io',
|
|
111
|
-
'static.crates.io',
|
|
112
|
-
],
|
|
113
|
-
deniedDomains: [],
|
|
114
|
-
},
|
|
115
|
-
filesystem: {
|
|
116
|
-
denyRead: ['/Users', '/home'],
|
|
117
|
-
allowRead: [
|
|
118
|
-
'.',
|
|
119
|
-
'/dev/null',
|
|
120
|
-
'~/.config/opencode',
|
|
121
|
-
'~/.config/git',
|
|
122
|
-
'~/.gitconfig',
|
|
123
|
-
'~/.local',
|
|
124
|
-
'~/.cargo',
|
|
125
|
-
],
|
|
126
|
-
allowWrite: ['.', '/tmp', '/dev/null'],
|
|
127
|
-
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
132
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function stringArray(value: unknown): string[] | undefined {
|
|
136
|
-
if (!Array.isArray(value)) return undefined;
|
|
137
|
-
return value.every((item) => typeof item === 'string') ? [...value] : undefined;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
|
|
141
|
-
if (!isRecord(value)) return undefined;
|
|
142
|
-
|
|
143
|
-
const config: Partial<SandboxNetworkConfig> = {};
|
|
144
|
-
if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
|
|
145
|
-
if (typeof value.allowLocalBinding === 'boolean')
|
|
146
|
-
config.allowLocalBinding = value.allowLocalBinding;
|
|
147
|
-
if (typeof value.allowAllUnixSockets === 'boolean')
|
|
148
|
-
config.allowAllUnixSockets = value.allowAllUnixSockets;
|
|
149
|
-
|
|
150
|
-
const allowUnixSockets = stringArray(value.allowUnixSockets);
|
|
151
|
-
if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
|
|
152
|
-
|
|
153
|
-
const allowedDomains = stringArray(value.allowedDomains);
|
|
154
|
-
if (allowedDomains) config.allowedDomains = allowedDomains;
|
|
155
|
-
|
|
156
|
-
const deniedDomains = stringArray(value.deniedDomains);
|
|
157
|
-
if (deniedDomains) config.deniedDomains = deniedDomains;
|
|
158
|
-
|
|
159
|
-
return config;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
|
|
163
|
-
if (!isRecord(value)) return undefined;
|
|
164
|
-
|
|
165
|
-
const config: Partial<SandboxFilesystemConfig> = {};
|
|
166
|
-
const denyRead = stringArray(value.denyRead);
|
|
167
|
-
if (denyRead) config.denyRead = denyRead;
|
|
168
|
-
|
|
169
|
-
const allowRead = stringArray(value.allowRead);
|
|
170
|
-
if (allowRead) config.allowRead = allowRead;
|
|
171
|
-
|
|
172
|
-
const allowWrite = stringArray(value.allowWrite);
|
|
173
|
-
if (allowWrite) config.allowWrite = allowWrite;
|
|
174
|
-
|
|
175
|
-
const denyWrite = stringArray(value.denyWrite);
|
|
176
|
-
if (denyWrite) config.denyWrite = denyWrite;
|
|
177
|
-
|
|
178
|
-
return config;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
182
|
-
if (!isRecord(value)) return {};
|
|
183
|
-
|
|
184
|
-
const config: SandboxConfigOverrides = {};
|
|
185
|
-
if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
|
|
186
|
-
|
|
187
|
-
const network = normalizeNetworkConfig(value.network);
|
|
188
|
-
if (network) config.network = network;
|
|
189
|
-
|
|
190
|
-
const filesystem = normalizeFilesystemConfig(value.filesystem);
|
|
191
|
-
if (filesystem) config.filesystem = filesystem;
|
|
192
|
-
|
|
193
|
-
return config;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOverrides {
|
|
197
|
-
if (!isRecord(options)) return {};
|
|
198
|
-
return normalizeConfig(isRecord(options.config) ? options.config : options);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
202
|
-
return {
|
|
203
|
-
enabled: overrides.enabled ?? base.enabled,
|
|
204
|
-
network: {
|
|
205
|
-
...base.network,
|
|
206
|
-
...overrides.network,
|
|
207
|
-
},
|
|
208
|
-
filesystem: {
|
|
209
|
-
...base.filesystem,
|
|
210
|
-
...overrides.filesystem,
|
|
211
|
-
},
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
|
|
216
|
-
return {
|
|
217
|
-
globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
|
|
218
|
-
projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
|
|
219
|
-
};
|
|
85
|
+
function isLandstripErrorReason(value: string): value is LandstripErrorResponse['reason'] {
|
|
86
|
+
return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorResponse['reason']);
|
|
220
87
|
}
|
|
221
88
|
|
|
222
|
-
function
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
227
|
-
} catch (error) {
|
|
228
|
-
console.error(`Warning: Could not parse ${configPath}: ${error}`);
|
|
229
|
-
return {};
|
|
230
|
-
}
|
|
89
|
+
function isLandstripOperation(
|
|
90
|
+
value: string,
|
|
91
|
+
): value is NonNullable<LandstripErrorResponse['operation']> {
|
|
92
|
+
return LANDSTRIP_OPERATIONS.has(value as NonNullable<LandstripErrorResponse['operation']>);
|
|
231
93
|
}
|
|
232
94
|
|
|
233
|
-
function
|
|
234
|
-
|
|
235
|
-
return deepMerge(
|
|
236
|
-
deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
|
|
237
|
-
optionOverrides,
|
|
238
|
-
);
|
|
95
|
+
function isLandstripErrorType(value: string): value is NonNullable<LandstripErrorResponse['type']> {
|
|
96
|
+
return LANDSTRIP_ERROR_TYPES.has(value as NonNullable<LandstripErrorResponse['type']>);
|
|
239
97
|
}
|
|
240
98
|
|
|
241
99
|
function expandPath(filePath: string, baseDirectory: string): string {
|
|
@@ -318,18 +176,6 @@ function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory:
|
|
|
318
176
|
return allowWrite.length === 0 || !matchesPattern(path, allowWrite, baseDirectory);
|
|
319
177
|
}
|
|
320
178
|
|
|
321
|
-
function extractDomainsFromCommand(command: string): string[] {
|
|
322
|
-
const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
|
|
323
|
-
const domains = new Set<string>();
|
|
324
|
-
let match: RegExpExecArray | null;
|
|
325
|
-
|
|
326
|
-
while ((match = urlRegex.exec(command)) !== null) {
|
|
327
|
-
domains.add(match[1]);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return [...domains];
|
|
331
|
-
}
|
|
332
|
-
|
|
333
179
|
function domainMatchesPattern(domain: string, pattern: string): boolean {
|
|
334
180
|
const normalizedDomain = domain.toLowerCase();
|
|
335
181
|
const normalizedPattern = pattern.toLowerCase();
|
|
@@ -527,7 +373,7 @@ function evaluateDomainPermission(
|
|
|
527
373
|
}
|
|
528
374
|
|
|
529
375
|
function landstripVersion(): string | null {
|
|
530
|
-
const result = spawnSync(
|
|
376
|
+
const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
|
|
531
377
|
if (result.status !== 0) return null;
|
|
532
378
|
return result.stdout.trim();
|
|
533
379
|
}
|
|
@@ -564,25 +410,19 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
|
|
|
564
410
|
if (key.length > 0 && value.length > 0) fields[key] = value;
|
|
565
411
|
}
|
|
566
412
|
|
|
567
|
-
if (
|
|
568
|
-
fields.reason &&
|
|
569
|
-
['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
|
|
570
|
-
fields.source
|
|
571
|
-
) {
|
|
413
|
+
if (fields.reason && isLandstripErrorReason(fields.reason)) {
|
|
572
414
|
const error: LandstripErrorResponse = {
|
|
573
|
-
reason: fields.reason
|
|
574
|
-
source: fields.source,
|
|
415
|
+
reason: fields.reason,
|
|
575
416
|
};
|
|
576
417
|
|
|
577
418
|
if (fields.file) error.file = fields.file;
|
|
419
|
+
if (fields.operation && isLandstripOperation(fields.operation)) {
|
|
420
|
+
error.operation = fields.operation;
|
|
421
|
+
}
|
|
578
422
|
if (fields.program) error.program = fields.program;
|
|
423
|
+
if (fields.source) error.source = fields.source;
|
|
579
424
|
|
|
580
|
-
if (
|
|
581
|
-
fields.type &&
|
|
582
|
-
['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
|
|
583
|
-
) {
|
|
584
|
-
error.type = fields.type as LandstripErrorResponse['type'];
|
|
585
|
-
}
|
|
425
|
+
if (fields.type && isLandstripErrorType(fields.type)) error.type = fields.type;
|
|
586
426
|
|
|
587
427
|
errors.push(error);
|
|
588
428
|
}
|
|
@@ -599,13 +439,16 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
|
|
|
599
439
|
if (err.file) {
|
|
600
440
|
parts.push(` (${err.file})`);
|
|
601
441
|
}
|
|
442
|
+
if (err.operation) {
|
|
443
|
+
parts.push(` ${err.operation}`);
|
|
444
|
+
}
|
|
602
445
|
if (err.program) {
|
|
603
446
|
parts.push(` ${err.program}`);
|
|
604
447
|
}
|
|
605
448
|
if (err.type) {
|
|
606
449
|
parts.push(`:${err.type}`);
|
|
607
450
|
}
|
|
608
|
-
parts.push(`: ${err.source}`);
|
|
451
|
+
if (err.source) parts.push(`: ${err.source}`);
|
|
609
452
|
|
|
610
453
|
return parts.join('');
|
|
611
454
|
})
|
|
@@ -838,12 +681,12 @@ function shellArgs(shell: string, command: string): string[] {
|
|
|
838
681
|
function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
|
|
839
682
|
const args = ['-p', policyPath, ...shellArgs(shell, command)];
|
|
840
683
|
|
|
841
|
-
return [
|
|
684
|
+
return [landstripBinaryPath(), ...args].map(shellQuote).join(' ');
|
|
842
685
|
}
|
|
843
686
|
|
|
844
687
|
function isGeneratedWrappedCommand(command: string): boolean {
|
|
845
688
|
return (
|
|
846
|
-
command.startsWith(`${shellQuote(
|
|
689
|
+
command.startsWith(`${shellQuote(landstripBinaryPath())} `) &&
|
|
847
690
|
command.includes(` ${shellQuote('-p')} `) &&
|
|
848
691
|
command.includes('opencode-landstrip-')
|
|
849
692
|
);
|
|
@@ -964,6 +807,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
964
807
|
|
|
965
808
|
function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
|
|
966
809
|
if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
|
|
810
|
+
client.tui
|
|
811
|
+
?.showToast?.({
|
|
812
|
+
body: {
|
|
813
|
+
title: 'Sandbox blocked',
|
|
814
|
+
message: decision.message.slice(0, 120),
|
|
815
|
+
variant: 'error',
|
|
816
|
+
},
|
|
817
|
+
})
|
|
818
|
+
?.catch?.(() => undefined);
|
|
967
819
|
throw errorWithConfigPaths(directory, decision.message);
|
|
968
820
|
}
|
|
969
821
|
|
|
@@ -995,7 +847,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
995
847
|
'# Sandbox Configuration',
|
|
996
848
|
'',
|
|
997
849
|
`Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
|
|
998
|
-
`landstrip: ${
|
|
850
|
+
`landstrip package binary: ${landstripBinaryPath()}`,
|
|
999
851
|
'',
|
|
1000
852
|
'Config files:',
|
|
1001
853
|
`- project: ${projectPath}`,
|
|
@@ -1082,7 +934,17 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1082
934
|
return landstripCheck;
|
|
1083
935
|
}
|
|
1084
936
|
|
|
1085
|
-
|
|
937
|
+
let version: string | null;
|
|
938
|
+
try {
|
|
939
|
+
version = landstripVersion();
|
|
940
|
+
} catch (error) {
|
|
941
|
+
landstripCheck = {
|
|
942
|
+
ok: false,
|
|
943
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
944
|
+
};
|
|
945
|
+
return landstripCheck;
|
|
946
|
+
}
|
|
947
|
+
|
|
1086
948
|
if (!version) {
|
|
1087
949
|
landstripCheck = {
|
|
1088
950
|
ok: false,
|
|
@@ -1094,7 +956,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1094
956
|
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
1095
957
|
landstripCheck = {
|
|
1096
958
|
ok: false,
|
|
1097
|
-
reason: `landstrip
|
|
959
|
+
reason: `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
|
|
1098
960
|
};
|
|
1099
961
|
return landstripCheck;
|
|
1100
962
|
}
|
|
@@ -1107,7 +969,14 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1107
969
|
if (sandboxDisabled) return null;
|
|
1108
970
|
|
|
1109
971
|
const config = loadConfig(directory, optionOverrides);
|
|
1110
|
-
if (!config.enabled)
|
|
972
|
+
if (!config.enabled) {
|
|
973
|
+
await notifyOnce(
|
|
974
|
+
`not-configured:${directory}`,
|
|
975
|
+
'Sandbox is not configured — no sandbox.json5 found',
|
|
976
|
+
'info',
|
|
977
|
+
);
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
1111
980
|
|
|
1112
981
|
const check = checkLandstrip();
|
|
1113
982
|
if (!check?.ok) {
|
|
@@ -1435,6 +1304,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1435
1304
|
if (input.command.trim() === '/sandbox') {
|
|
1436
1305
|
const config = loadConfig(directory, optionOverrides);
|
|
1437
1306
|
pushCommandText(input, output, sandboxSummary(config));
|
|
1307
|
+
await client.tui
|
|
1308
|
+
?.showToast?.({
|
|
1309
|
+
body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
|
|
1310
|
+
})
|
|
1311
|
+
?.catch?.(() => undefined);
|
|
1438
1312
|
return;
|
|
1439
1313
|
}
|
|
1440
1314
|
|
|
@@ -1453,6 +1327,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1453
1327
|
output,
|
|
1454
1328
|
'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
|
|
1455
1329
|
);
|
|
1330
|
+
await client.tui
|
|
1331
|
+
?.showToast?.({
|
|
1332
|
+
body: {
|
|
1333
|
+
title: 'Sandbox',
|
|
1334
|
+
message: 'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
|
|
1335
|
+
variant: 'warning',
|
|
1336
|
+
},
|
|
1337
|
+
})
|
|
1338
|
+
?.catch?.(() => undefined);
|
|
1456
1339
|
return;
|
|
1457
1340
|
}
|
|
1458
1341
|
|
|
@@ -1466,7 +1349,30 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1466
1349
|
return;
|
|
1467
1350
|
}
|
|
1468
1351
|
sandboxDisabled = false;
|
|
1469
|
-
|
|
1352
|
+
const config = await activeConfig();
|
|
1353
|
+
if (!config) {
|
|
1354
|
+
pushCommandText(
|
|
1355
|
+
input,
|
|
1356
|
+
output,
|
|
1357
|
+
'Sandbox re-enabled but no sandbox.json5 found — no rules active.\nCreate sandbox.json5 to enforce sandboxing.',
|
|
1358
|
+
);
|
|
1359
|
+
await client.tui
|
|
1360
|
+
?.showToast?.({
|
|
1361
|
+
body: {
|
|
1362
|
+
title: 'Sandbox',
|
|
1363
|
+
message: 'Sandbox re-enabled but no sandbox.json5 found — no rules active.',
|
|
1364
|
+
variant: 'warning',
|
|
1365
|
+
},
|
|
1366
|
+
})
|
|
1367
|
+
?.catch?.(() => undefined);
|
|
1368
|
+
} else {
|
|
1369
|
+
pushCommandText(input, output, 'Sandbox re-enabled.');
|
|
1370
|
+
await client.tui
|
|
1371
|
+
?.showToast?.({
|
|
1372
|
+
body: { title: 'Sandbox', message: 'Sandbox re-enabled.', variant: 'success' },
|
|
1373
|
+
})
|
|
1374
|
+
?.catch?.(() => undefined);
|
|
1375
|
+
}
|
|
1470
1376
|
return;
|
|
1471
1377
|
}
|
|
1472
1378
|
|
|
@@ -1482,6 +1388,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1482
1388
|
for (const path of extractCandidatePaths(shellCommand)) {
|
|
1483
1389
|
const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
|
|
1484
1390
|
if (readDecision.status === 'deny') {
|
|
1391
|
+
client.tui
|
|
1392
|
+
?.showToast?.({
|
|
1393
|
+
body: {
|
|
1394
|
+
title: 'Sandbox blocked',
|
|
1395
|
+
message: readDecision.message.slice(0, 120),
|
|
1396
|
+
variant: 'error',
|
|
1397
|
+
},
|
|
1398
|
+
})
|
|
1399
|
+
?.catch?.(() => undefined);
|
|
1485
1400
|
throw errorWithConfigPaths(directory, readDecision.message);
|
|
1486
1401
|
}
|
|
1487
1402
|
|
|
@@ -1492,6 +1407,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1492
1407
|
effectiveAllowWrite,
|
|
1493
1408
|
);
|
|
1494
1409
|
if (writeDecision.status === 'deny') {
|
|
1410
|
+
client.tui
|
|
1411
|
+
?.showToast?.({
|
|
1412
|
+
body: {
|
|
1413
|
+
title: 'Sandbox blocked',
|
|
1414
|
+
message: writeDecision.message.slice(0, 120),
|
|
1415
|
+
variant: 'error',
|
|
1416
|
+
},
|
|
1417
|
+
})
|
|
1418
|
+
?.catch?.(() => undefined);
|
|
1495
1419
|
throw errorWithConfigPaths(directory, writeDecision.message);
|
|
1496
1420
|
}
|
|
1497
1421
|
}
|
|
@@ -1507,6 +1431,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1507
1431
|
blockedDomain.reason === 'deniedDomains'
|
|
1508
1432
|
? 'is blocked by network.deniedDomains'
|
|
1509
1433
|
: 'is not in network.allowedDomains';
|
|
1434
|
+
client.tui
|
|
1435
|
+
?.showToast?.({
|
|
1436
|
+
body: {
|
|
1437
|
+
title: 'Sandbox blocked',
|
|
1438
|
+
message: `Network access denied for "${blockedDomain.domain}"`,
|
|
1439
|
+
variant: 'error',
|
|
1440
|
+
},
|
|
1441
|
+
})
|
|
1442
|
+
?.catch?.(() => undefined);
|
|
1510
1443
|
throw errorWithConfigPaths(
|
|
1511
1444
|
directory,
|
|
1512
1445
|
`Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"index.ts",
|
|
18
18
|
"tui.ts",
|
|
19
|
+
"shared.ts",
|
|
19
20
|
"landstrip.d.ts",
|
|
20
21
|
"README.md",
|
|
21
22
|
"sandbox.json"
|
|
@@ -37,8 +38,8 @@
|
|
|
37
38
|
}
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
40
|
-
"fmt": "oxfmt index.ts tui.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
|
|
41
|
-
"lint": "oxlint index.ts tui.ts",
|
|
41
|
+
"fmt": "oxfmt index.ts tui.ts shared.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
|
|
42
|
+
"lint": "oxlint index.ts tui.ts shared.ts",
|
|
42
43
|
"check": "tsc --noEmit",
|
|
43
44
|
"test": "node --test test/*.test.mjs",
|
|
44
45
|
"all": "npm run fmt && npm run lint && npm run check && npm test",
|
|
@@ -48,10 +49,10 @@
|
|
|
48
49
|
"ci:test": "npm test"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@jarkkojs/landstrip": "^0.11.
|
|
52
|
+
"@jarkkojs/landstrip": "^0.11.11"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
|
-
"@opencode-ai/plugin": "^1.17.
|
|
55
|
+
"@opencode-ai/plugin": "^1.17.7",
|
|
55
56
|
"@opentui/core": ">=0.3.4",
|
|
56
57
|
"@opentui/keymap": ">=0.3.4",
|
|
57
58
|
"@opentui/solid": ">=0.3.4",
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
"typescript": "^5.8.2"
|
|
62
63
|
},
|
|
63
64
|
"peerDependencies": {
|
|
64
|
-
"@opencode-ai/plugin": "^1.17.
|
|
65
|
+
"@opencode-ai/plugin": "^1.17.7"
|
|
65
66
|
},
|
|
66
67
|
"peerDependenciesMeta": {
|
|
67
68
|
"@opencode-ai/plugin": {
|
|
@@ -69,6 +70,6 @@
|
|
|
69
70
|
}
|
|
70
71
|
},
|
|
71
72
|
"engines": {
|
|
72
|
-
"opencode": ">=1.17.
|
|
73
|
+
"opencode": ">=1.17.7"
|
|
73
74
|
}
|
|
74
75
|
}
|