opencode-landstrip 0.3.12 → 0.14.0
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 +14 -8
- package/index.ts +107 -316
- package/landstrip.d.ts +1 -1
- package/package.json +9 -8
- package/shared.ts +318 -0
- package/tui.ts +171 -328
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# opencode-landstrip
|
|
2
2
|
|
|
3
3
|
Landlock-based sandboxing for [opencode](https://opencode.ai/) using
|
|
4
|
-
[`landstrip`](https://github.com/
|
|
4
|
+
[`landstrip`](https://github.com/landstrip/landstrip).
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
@@ -26,9 +26,11 @@ manually:
|
|
|
26
26
|
|
|
27
27
|
`opencode plugin install opencode-landstrip` configures both entrypoints.
|
|
28
28
|
|
|
29
|
-
This installs `opencode-landstrip` and its `@
|
|
29
|
+
This installs `opencode-landstrip` and its `@landstrip/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
|
|
@@ -48,12 +50,16 @@ access is off unless domains are allowed, reads are limited to the project,
|
|
|
48
50
|
`~/.gitconfig`, and `/dev/null`, and writes are limited to the project and
|
|
49
51
|
`/dev/null`.
|
|
50
52
|
|
|
51
|
-
Run `/sandbox` in the TUI to inspect the active sandbox configuration.
|
|
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.
|
|
52
56
|
|
|
53
|
-
When OpenCode asks for a sandboxed permission, the TUI plugin
|
|
54
|
-
|
|
55
|
-
|
|
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`.
|
|
57
63
|
|
|
58
64
|
OpenCode's current plugin API allows wrapping AI `bash` tool calls, but does not
|
|
59
65
|
allow a plugin to replace manually typed shell-mode commands with a landstrip
|
|
@@ -76,5 +82,5 @@ Set `enabled` to `false` in `sandbox.json`, or pass plugin options:
|
|
|
76
82
|
`opencode-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
|
|
77
83
|
information.
|
|
78
84
|
|
|
79
|
-
The bundled `@
|
|
85
|
+
The bundled `@landstrip/landstrip` package is licensed separately as
|
|
80
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: {
|
|
@@ -52,20 +32,12 @@ interface LandstripPolicy {
|
|
|
52
32
|
filesystem: SandboxFilesystemConfig;
|
|
53
33
|
}
|
|
54
34
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
source?: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface SandboxConfigOverrides {
|
|
65
|
-
enabled?: boolean;
|
|
66
|
-
network?: Partial<SandboxNetworkConfig>;
|
|
67
|
-
filesystem?: Partial<SandboxFilesystemConfig>;
|
|
68
|
-
}
|
|
35
|
+
type LandstripTrap =
|
|
36
|
+
| { kind: 'Filesystem'; operation: 'read' | 'write'; path: string; mechanism: string }
|
|
37
|
+
| { kind: 'Network'; operation: string; target: string; mechanism: string }
|
|
38
|
+
| { kind: 'Launch'; program: string; message: string }
|
|
39
|
+
| { kind: 'Usage'; message: string }
|
|
40
|
+
| { kind: 'Internal'; fields: Record<string, string> };
|
|
69
41
|
|
|
70
42
|
interface BashSandboxState {
|
|
71
43
|
originalCommand: string;
|
|
@@ -86,189 +58,13 @@ interface SandboxPermissionDecision {
|
|
|
86
58
|
|
|
87
59
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
88
60
|
|
|
89
|
-
const LANDSTRIP_VERSION = [0,
|
|
61
|
+
const LANDSTRIP_VERSION = [0, 14, 0] as const;
|
|
90
62
|
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
91
|
-
const
|
|
92
|
-
'Other',
|
|
93
|
-
'AccessDenied',
|
|
94
|
-
'LaunchFailed',
|
|
95
|
-
'SetupFailed',
|
|
96
|
-
'Usage',
|
|
97
|
-
]);
|
|
98
|
-
const LANDSTRIP_OPERATIONS = new Set<NonNullable<LandstripErrorResponse['operation']>>([
|
|
99
|
-
'read',
|
|
100
|
-
'write',
|
|
101
|
-
]);
|
|
102
|
-
const LANDSTRIP_ERROR_TYPES = new Set<NonNullable<LandstripErrorResponse['type']>>([
|
|
103
|
-
'filesystem',
|
|
104
|
-
'network',
|
|
105
|
-
'platform',
|
|
106
|
-
'launch',
|
|
107
|
-
'encoding',
|
|
108
|
-
]);
|
|
63
|
+
const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
|
|
109
64
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
110
|
-
const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
111
|
-
'@jarkkojs/landstrip',
|
|
112
|
-
'@jarkkojs/landstrip-darwin-arm64',
|
|
113
|
-
'@jarkkojs/landstrip-darwin-x64',
|
|
114
|
-
'@jarkkojs/landstrip-linux-x64',
|
|
115
|
-
'@jarkkojs/landstrip-win32-x64',
|
|
116
|
-
]);
|
|
117
|
-
|
|
118
|
-
function isLandstripErrorReason(value: string): value is LandstripErrorResponse['reason'] {
|
|
119
|
-
return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorResponse['reason']);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function isLandstripOperation(
|
|
123
|
-
value: string,
|
|
124
|
-
): value is NonNullable<LandstripErrorResponse['operation']> {
|
|
125
|
-
return LANDSTRIP_OPERATIONS.has(value as NonNullable<LandstripErrorResponse['operation']>);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function isLandstripErrorType(value: string): value is NonNullable<LandstripErrorResponse['type']> {
|
|
129
|
-
return LANDSTRIP_ERROR_TYPES.has(value as NonNullable<LandstripErrorResponse['type']>);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const DEFAULT_CONFIG: SandboxConfig = {
|
|
133
|
-
enabled: true,
|
|
134
|
-
network: {
|
|
135
|
-
allowNetwork: false,
|
|
136
|
-
allowLocalBinding: false,
|
|
137
|
-
allowAllUnixSockets: false,
|
|
138
|
-
allowUnixSockets: [],
|
|
139
|
-
allowedDomains: [],
|
|
140
|
-
deniedDomains: [],
|
|
141
|
-
},
|
|
142
|
-
filesystem: {
|
|
143
|
-
denyRead: ['/Users', '/home'],
|
|
144
|
-
allowRead: ['.', '~/.gitconfig', '/dev/null'],
|
|
145
|
-
allowWrite: ['.', '/dev/null'],
|
|
146
|
-
denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
151
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function stringArray(value: unknown): string[] | undefined {
|
|
155
|
-
if (!Array.isArray(value)) return undefined;
|
|
156
|
-
return value.every((item) => typeof item === 'string') ? [...value] : undefined;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
|
|
160
|
-
if (!isRecord(value)) return undefined;
|
|
161
|
-
|
|
162
|
-
const config: Partial<SandboxNetworkConfig> = {};
|
|
163
|
-
if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
|
|
164
|
-
if (typeof value.allowLocalBinding === 'boolean')
|
|
165
|
-
config.allowLocalBinding = value.allowLocalBinding;
|
|
166
|
-
if (typeof value.allowAllUnixSockets === 'boolean')
|
|
167
|
-
config.allowAllUnixSockets = value.allowAllUnixSockets;
|
|
168
|
-
|
|
169
|
-
const allowUnixSockets = stringArray(value.allowUnixSockets);
|
|
170
|
-
if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
|
|
171
65
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const deniedDomains = stringArray(value.deniedDomains);
|
|
176
|
-
if (deniedDomains) config.deniedDomains = deniedDomains;
|
|
177
|
-
|
|
178
|
-
return config;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
|
|
182
|
-
if (!isRecord(value)) return undefined;
|
|
183
|
-
|
|
184
|
-
const config: Partial<SandboxFilesystemConfig> = {};
|
|
185
|
-
const denyRead = stringArray(value.denyRead);
|
|
186
|
-
if (denyRead) config.denyRead = denyRead;
|
|
187
|
-
|
|
188
|
-
const allowRead = stringArray(value.allowRead);
|
|
189
|
-
if (allowRead) config.allowRead = allowRead;
|
|
190
|
-
|
|
191
|
-
const allowWrite = stringArray(value.allowWrite);
|
|
192
|
-
if (allowWrite) config.allowWrite = allowWrite;
|
|
193
|
-
|
|
194
|
-
const denyWrite = stringArray(value.denyWrite);
|
|
195
|
-
if (denyWrite) config.denyWrite = denyWrite;
|
|
196
|
-
|
|
197
|
-
return config;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
201
|
-
if (!isRecord(value)) return {};
|
|
202
|
-
|
|
203
|
-
const config: SandboxConfigOverrides = {};
|
|
204
|
-
if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
|
|
205
|
-
|
|
206
|
-
const network = normalizeNetworkConfig(value.network);
|
|
207
|
-
if (network) config.network = network;
|
|
208
|
-
|
|
209
|
-
const filesystem = normalizeFilesystemConfig(value.filesystem);
|
|
210
|
-
if (filesystem) config.filesystem = filesystem;
|
|
211
|
-
|
|
212
|
-
return config;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOverrides {
|
|
216
|
-
if (!isRecord(options)) return {};
|
|
217
|
-
return normalizeConfig(isRecord(options.config) ? options.config : options);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function mergeArray(base: string[], override?: string[]): string[] {
|
|
221
|
-
if (!override) return base;
|
|
222
|
-
return [...new Set([...base, ...override])];
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
226
|
-
const network = overrides.network;
|
|
227
|
-
const filesystem = overrides.filesystem;
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
enabled: overrides.enabled ?? base.enabled,
|
|
231
|
-
network: {
|
|
232
|
-
allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
|
|
233
|
-
allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
|
|
234
|
-
allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
|
|
235
|
-
allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
|
|
236
|
-
allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
|
|
237
|
-
deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
|
|
238
|
-
},
|
|
239
|
-
filesystem: {
|
|
240
|
-
denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
|
|
241
|
-
allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
|
|
242
|
-
allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
|
|
243
|
-
denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
|
|
244
|
-
},
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
|
|
249
|
-
return {
|
|
250
|
-
globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
|
|
251
|
-
projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function readConfigFile(configPath: string): SandboxConfigOverrides {
|
|
256
|
-
if (!existsSync(configPath)) return {};
|
|
257
|
-
|
|
258
|
-
try {
|
|
259
|
-
return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
260
|
-
} catch (error) {
|
|
261
|
-
console.error(`Warning: Could not parse ${configPath}: ${error}`);
|
|
262
|
-
return {};
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
|
|
267
|
-
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
268
|
-
return deepMerge(
|
|
269
|
-
deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
|
|
270
|
-
optionOverrides,
|
|
271
|
-
);
|
|
66
|
+
function isLandstripOperation(value: unknown): value is 'read' | 'write' {
|
|
67
|
+
return typeof value === 'string' && LANDSTRIP_OPERATIONS.has(value as 'read' | 'write');
|
|
272
68
|
}
|
|
273
69
|
|
|
274
70
|
function expandPath(filePath: string, baseDirectory: string): string {
|
|
@@ -281,33 +77,6 @@ function configuredShellPath(config: unknown): string | undefined {
|
|
|
281
77
|
return typeof config.shell === 'string' ? config.shell : undefined;
|
|
282
78
|
}
|
|
283
79
|
|
|
284
|
-
function landstripBinaryPath(): string {
|
|
285
|
-
const filePath = realpathSync.native(binaryPath());
|
|
286
|
-
let probe = dirname(filePath);
|
|
287
|
-
|
|
288
|
-
while (true) {
|
|
289
|
-
const manifestPath = join(probe, 'package.json');
|
|
290
|
-
if (existsSync(manifestPath)) {
|
|
291
|
-
try {
|
|
292
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
|
|
293
|
-
if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
|
|
294
|
-
return filePath;
|
|
295
|
-
}
|
|
296
|
-
} catch {
|
|
297
|
-
// malformed package.json — continue walking to parent
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const parent = dirname(probe);
|
|
302
|
-
if (parent === probe) break;
|
|
303
|
-
probe = parent;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
throw new Error(
|
|
307
|
-
`Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
80
|
function canonicalizePath(filePath: string, baseDirectory: string): string {
|
|
312
81
|
const abs = expandPath(filePath, baseDirectory);
|
|
313
82
|
|
|
@@ -378,18 +147,6 @@ function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory:
|
|
|
378
147
|
return allowWrite.length === 0 || !matchesPattern(path, allowWrite, baseDirectory);
|
|
379
148
|
}
|
|
380
149
|
|
|
381
|
-
function extractDomainsFromCommand(command: string): string[] {
|
|
382
|
-
const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
|
|
383
|
-
const domains = new Set<string>();
|
|
384
|
-
let match: RegExpExecArray | null;
|
|
385
|
-
|
|
386
|
-
while ((match = urlRegex.exec(command)) !== null) {
|
|
387
|
-
domains.add(match[1]);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
return [...domains];
|
|
391
|
-
}
|
|
392
|
-
|
|
393
150
|
function domainMatchesPattern(domain: string, pattern: string): boolean {
|
|
394
151
|
const normalizedDomain = domain.toLowerCase();
|
|
395
152
|
const normalizedPattern = pattern.toLowerCase();
|
|
@@ -456,13 +213,16 @@ function extractBlockedPath(
|
|
|
456
213
|
);
|
|
457
214
|
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
458
215
|
|
|
459
|
-
// Landstrip structured
|
|
216
|
+
// Landstrip structured trap format carrying a denied path
|
|
460
217
|
const landstripErrors = parseLandstripErrors(output);
|
|
461
|
-
for (const
|
|
462
|
-
if (
|
|
218
|
+
for (const trap of landstripErrors) {
|
|
219
|
+
if (trap.kind === 'Filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
|
|
220
|
+
if (trap.kind === 'Internal' && trap.fields.file) {
|
|
221
|
+
return normalizeBlockedPath(trap.fields.file, baseDirectory);
|
|
222
|
+
}
|
|
463
223
|
}
|
|
464
224
|
|
|
465
|
-
// If landstrip reported
|
|
225
|
+
// If landstrip reported a trap but without a path, try to
|
|
466
226
|
// extract the blocked path from the command itself
|
|
467
227
|
if (landstripErrors.length > 0 && command) {
|
|
468
228
|
for (const candidate of extractCandidatePaths(command)) {
|
|
@@ -610,63 +370,94 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
610
370
|
return true;
|
|
611
371
|
}
|
|
612
372
|
|
|
613
|
-
function
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
373
|
+
function decodeLandstripTrap(value: unknown): LandstripTrap | null {
|
|
374
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
|
375
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
376
|
+
if (entries.length !== 1) return null;
|
|
377
|
+
const [kind, payload] = entries[0];
|
|
618
378
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
379
|
+
switch (kind) {
|
|
380
|
+
case 'Filesystem': {
|
|
381
|
+
if (!Array.isArray(payload)) return null;
|
|
382
|
+
const [operation, path, mechanism] = payload;
|
|
383
|
+
if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
|
|
384
|
+
return { kind, operation, path, mechanism: typeof mechanism === 'string' ? mechanism : '' };
|
|
625
385
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
};
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (
|
|
634
|
-
|
|
386
|
+
case 'Network': {
|
|
387
|
+
if (!Array.isArray(payload)) return null;
|
|
388
|
+
const [operation, target, mechanism] = payload;
|
|
389
|
+
if (typeof operation !== 'string' || typeof target !== 'string') return null;
|
|
390
|
+
return { kind, operation, target, mechanism: typeof mechanism === 'string' ? mechanism : '' };
|
|
391
|
+
}
|
|
392
|
+
case 'Launch': {
|
|
393
|
+
if (!Array.isArray(payload)) return null;
|
|
394
|
+
const [program, message] = payload;
|
|
395
|
+
if (typeof program !== 'string') return null;
|
|
396
|
+
return { kind, program, message: typeof message === 'string' ? message : '' };
|
|
397
|
+
}
|
|
398
|
+
case 'Usage': {
|
|
399
|
+
if (typeof payload !== 'string') return null;
|
|
400
|
+
return { kind, message: payload };
|
|
401
|
+
}
|
|
402
|
+
case 'Internal': {
|
|
403
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null;
|
|
404
|
+
const fields: Record<string, string> = {};
|
|
405
|
+
for (const [key, val] of Object.entries(payload as Record<string, unknown>)) {
|
|
406
|
+
fields[key] = typeof val === 'string' ? val : JSON.stringify(val);
|
|
635
407
|
}
|
|
636
|
-
|
|
637
|
-
|
|
408
|
+
return { kind, fields };
|
|
409
|
+
}
|
|
410
|
+
default:
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function parseLandstripErrors(output: string): LandstripTrap[] {
|
|
416
|
+
const traps: LandstripTrap[] = [];
|
|
638
417
|
|
|
639
|
-
|
|
418
|
+
for (const line of output.split('\n')) {
|
|
419
|
+
const trimmed = line.trim();
|
|
420
|
+
if (trimmed.length === 0 || trimmed[0] !== '{') continue;
|
|
640
421
|
|
|
641
|
-
|
|
422
|
+
let parsed: unknown;
|
|
423
|
+
try {
|
|
424
|
+
parsed = JSON.parse(trimmed);
|
|
425
|
+
} catch {
|
|
426
|
+
continue;
|
|
642
427
|
}
|
|
428
|
+
|
|
429
|
+
const trap = decodeLandstripTrap(parsed);
|
|
430
|
+
if (trap) traps.push(trap);
|
|
643
431
|
}
|
|
644
432
|
|
|
645
|
-
return
|
|
433
|
+
return traps;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function formatLandstripTrap(trap: LandstripTrap): string {
|
|
437
|
+
switch (trap.kind) {
|
|
438
|
+
case 'Filesystem':
|
|
439
|
+
return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
|
|
440
|
+
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
441
|
+
}`;
|
|
442
|
+
case 'Network':
|
|
443
|
+
return `landstrip: network ${trap.operation} denied (${trap.target})${
|
|
444
|
+
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
445
|
+
}`;
|
|
446
|
+
case 'Launch':
|
|
447
|
+
return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
|
|
448
|
+
case 'Usage':
|
|
449
|
+
return `landstrip: usage error: ${trap.message}`;
|
|
450
|
+
case 'Internal': {
|
|
451
|
+
const detail = Object.entries(trap.fields)
|
|
452
|
+
.map(([key, val]) => `${key}: ${val}`)
|
|
453
|
+
.join(', ');
|
|
454
|
+
return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
646
457
|
}
|
|
647
458
|
|
|
648
|
-
function formatLandstripErrors(
|
|
649
|
-
return
|
|
650
|
-
.map((err) => {
|
|
651
|
-
const parts: string[] = [`landstrip: ${err.reason}`];
|
|
652
|
-
|
|
653
|
-
if (err.file) {
|
|
654
|
-
parts.push(` (${err.file})`);
|
|
655
|
-
}
|
|
656
|
-
if (err.operation) {
|
|
657
|
-
parts.push(` ${err.operation}`);
|
|
658
|
-
}
|
|
659
|
-
if (err.program) {
|
|
660
|
-
parts.push(` ${err.program}`);
|
|
661
|
-
}
|
|
662
|
-
if (err.type) {
|
|
663
|
-
parts.push(`:${err.type}`);
|
|
664
|
-
}
|
|
665
|
-
if (err.source) parts.push(`: ${err.source}`);
|
|
666
|
-
|
|
667
|
-
return parts.join('');
|
|
668
|
-
})
|
|
669
|
-
.join('\n');
|
|
459
|
+
function formatLandstripErrors(traps: LandstripTrap[]): string {
|
|
460
|
+
return traps.map(formatLandstripTrap).join('\n');
|
|
670
461
|
}
|
|
671
462
|
|
|
672
463
|
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
@@ -1162,7 +953,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1162
953
|
if (!version) {
|
|
1163
954
|
landstripCheck = {
|
|
1164
955
|
ok: false,
|
|
1165
|
-
reason: `landstrip was not found. Reinstall with: npm install @
|
|
956
|
+
reason: `landstrip was not found. Reinstall with: npm install @landstrip/landstrip`,
|
|
1166
957
|
};
|
|
1167
958
|
return landstripCheck;
|
|
1168
959
|
}
|
package/landstrip.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/
|
|
14
|
+
"url": "git+https://github.com/landstrip/opencode-landstrip.git"
|
|
15
15
|
},
|
|
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
|
-
"@
|
|
52
|
+
"@landstrip/landstrip": "^0.14.5"
|
|
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
|
}
|