opencode-landstrip 0.1.1 → 0.2.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 +5 -11
- package/index.ts +74 -62
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -3,17 +3,6 @@
|
|
|
3
3
|
Landlock-based sandboxing for [opencode](https://opencode.ai/) using
|
|
4
4
|
[`landstrip`](https://github.com/jarkkojs/landstrip).
|
|
5
5
|
|
|
6
|
-
## Prerequisites
|
|
7
|
-
|
|
8
|
-
Install `landstrip` and make sure it is on the `PATH` used to launch opencode:
|
|
9
|
-
|
|
10
|
-
```bash
|
|
11
|
-
cargo install landstrip
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
`landstrip` supports Linux, macOS, and Windows. On other platforms this plugin
|
|
15
|
-
loads but leaves sandboxing disabled.
|
|
16
|
-
|
|
17
6
|
## Install
|
|
18
7
|
|
|
19
8
|
Add the plugin to `opencode.json`:
|
|
@@ -25,6 +14,11 @@ Add the plugin to `opencode.json`:
|
|
|
25
14
|
}
|
|
26
15
|
```
|
|
27
16
|
|
|
17
|
+
This installs `opencode-landstrip` and its `@jarkkojs/landstrip` dependency, which
|
|
18
|
+
includes platform-specific native binaries for Linux, macOS, and Windows.
|
|
19
|
+
|
|
20
|
+
On unsupported platforms the plugin loads but leaves sandboxing disabled.
|
|
21
|
+
|
|
28
22
|
## Configure
|
|
29
23
|
|
|
30
24
|
Create `.opencode/sandbox.json` in a project or
|
package/index.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Hooks, Plugin, PluginInput, PluginOptions } from '@opencode-ai/plugin';
|
|
5
5
|
|
|
6
|
+
import { binaryPath } from '@jarkkojs/landstrip';
|
|
7
|
+
|
|
6
8
|
import { spawnSync } from 'node:child_process';
|
|
7
9
|
import {
|
|
8
10
|
existsSync,
|
|
@@ -32,16 +34,10 @@ interface SandboxNetworkConfig {
|
|
|
32
34
|
deniedDomains: string[];
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
interface LandstripConfig {
|
|
36
|
-
command: string;
|
|
37
|
-
debug: boolean;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
37
|
interface SandboxConfig {
|
|
41
38
|
enabled: boolean;
|
|
42
39
|
network: SandboxNetworkConfig;
|
|
43
40
|
filesystem: SandboxFilesystemConfig;
|
|
44
|
-
landstrip: LandstripConfig;
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
interface LandstripPolicy {
|
|
@@ -54,11 +50,19 @@ interface LandstripPolicy {
|
|
|
54
50
|
filesystem: SandboxFilesystemConfig;
|
|
55
51
|
}
|
|
56
52
|
|
|
53
|
+
interface LandstripErrorResponse {
|
|
54
|
+
category: 'policy' | 'tool' | 'platform' | 'system';
|
|
55
|
+
file?: string;
|
|
56
|
+
program?: string;
|
|
57
|
+
target?: 'filesystem' | 'network' | 'platform';
|
|
58
|
+
kind?: 'launch' | 'encoding';
|
|
59
|
+
message: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
57
62
|
interface SandboxConfigOverrides {
|
|
58
63
|
enabled?: boolean;
|
|
59
64
|
network?: Partial<SandboxNetworkConfig>;
|
|
60
65
|
filesystem?: Partial<SandboxFilesystemConfig>;
|
|
61
|
-
landstrip?: Partial<LandstripConfig>;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
interface BashSandboxState {
|
|
@@ -71,7 +75,7 @@ interface BashSandboxState {
|
|
|
71
75
|
|
|
72
76
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
73
77
|
|
|
74
|
-
const LANDSTRIP_VERSION = [0,
|
|
78
|
+
const LANDSTRIP_VERSION = [0, 9, 2] as const;
|
|
75
79
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
76
80
|
|
|
77
81
|
const DEFAULT_CONFIG: SandboxConfig = {
|
|
@@ -103,10 +107,6 @@ const DEFAULT_CONFIG: SandboxConfig = {
|
|
|
103
107
|
allowWrite: ['.', '/tmp'],
|
|
104
108
|
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
105
109
|
},
|
|
106
|
-
landstrip: {
|
|
107
|
-
command: 'landstrip',
|
|
108
|
-
debug: false,
|
|
109
|
-
},
|
|
110
110
|
};
|
|
111
111
|
|
|
112
112
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -158,15 +158,6 @@ function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemCon
|
|
|
158
158
|
return config;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
function normalizeLandstripConfig(value: unknown): Partial<LandstripConfig> | undefined {
|
|
162
|
-
if (!isRecord(value)) return undefined;
|
|
163
|
-
|
|
164
|
-
const config: Partial<LandstripConfig> = {};
|
|
165
|
-
if (typeof value.command === 'string') config.command = value.command;
|
|
166
|
-
if (typeof value.debug === 'boolean') config.debug = value.debug;
|
|
167
|
-
return config;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
161
|
function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
171
162
|
if (!isRecord(value)) return {};
|
|
172
163
|
|
|
@@ -179,9 +170,6 @@ function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
|
179
170
|
const filesystem = normalizeFilesystemConfig(value.filesystem);
|
|
180
171
|
if (filesystem) config.filesystem = filesystem;
|
|
181
172
|
|
|
182
|
-
const landstrip = normalizeLandstripConfig(value.landstrip);
|
|
183
|
-
if (landstrip) config.landstrip = landstrip;
|
|
184
|
-
|
|
185
173
|
return config;
|
|
186
174
|
}
|
|
187
175
|
|
|
@@ -201,10 +189,6 @@ function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): Sand
|
|
|
201
189
|
...base.filesystem,
|
|
202
190
|
...overrides.filesystem,
|
|
203
191
|
},
|
|
204
|
-
landstrip: {
|
|
205
|
-
...base.landstrip,
|
|
206
|
-
...overrides.landstrip,
|
|
207
|
-
},
|
|
208
192
|
};
|
|
209
193
|
}
|
|
210
194
|
|
|
@@ -364,8 +348,8 @@ function firstBlockedDomain(
|
|
|
364
348
|
return null;
|
|
365
349
|
}
|
|
366
350
|
|
|
367
|
-
function landstripVersion(
|
|
368
|
-
const result = spawnSync(
|
|
351
|
+
function landstripVersion(): string | null {
|
|
352
|
+
const result = spawnSync(binaryPath(), ['--version'], { encoding: 'utf-8' });
|
|
369
353
|
if (result.status !== 0) return null;
|
|
370
354
|
return result.stdout.trim();
|
|
371
355
|
}
|
|
@@ -388,6 +372,52 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
388
372
|
return true;
|
|
389
373
|
}
|
|
390
374
|
|
|
375
|
+
function parseLandstripErrors(output: string): LandstripErrorResponse[] {
|
|
376
|
+
const errors: LandstripErrorResponse[] = [];
|
|
377
|
+
|
|
378
|
+
for (const line of output.split('\n')) {
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(line);
|
|
381
|
+
|
|
382
|
+
if (
|
|
383
|
+
typeof parsed === 'object' &&
|
|
384
|
+
parsed !== null &&
|
|
385
|
+
typeof parsed.category === 'string' &&
|
|
386
|
+
['policy', 'tool', 'platform', 'system'].includes(parsed.category) &&
|
|
387
|
+
typeof parsed.message === 'string' &&
|
|
388
|
+
parsed.message.length > 0
|
|
389
|
+
) {
|
|
390
|
+
errors.push(parsed as LandstripErrorResponse);
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
// ignore non-JSON lines
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return errors;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
|
|
401
|
+
return errors
|
|
402
|
+
.map((err) => {
|
|
403
|
+
const parts: string[] = [`landstrip: ${err.category}`];
|
|
404
|
+
|
|
405
|
+
if (err.target) {
|
|
406
|
+
parts.push(`(${err.target})`);
|
|
407
|
+
}
|
|
408
|
+
if (err.program) {
|
|
409
|
+
parts.push(` ${err.program}`);
|
|
410
|
+
}
|
|
411
|
+
if (err.kind) {
|
|
412
|
+
parts.push(`:${err.kind}`);
|
|
413
|
+
}
|
|
414
|
+
parts.push(`: ${err.message}`);
|
|
415
|
+
|
|
416
|
+
return parts.join('');
|
|
417
|
+
})
|
|
418
|
+
.join('\n');
|
|
419
|
+
}
|
|
420
|
+
|
|
391
421
|
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
392
422
|
const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
393
423
|
if (bracketMatch) {
|
|
@@ -606,26 +636,15 @@ function shellArgs(shell: string, command: string): string[] {
|
|
|
606
636
|
return [shell, '-lc', command];
|
|
607
637
|
}
|
|
608
638
|
|
|
609
|
-
function buildWrappedCommand(
|
|
610
|
-
|
|
611
|
-
policyPath: string,
|
|
612
|
-
shell: string,
|
|
613
|
-
command: string,
|
|
614
|
-
): string {
|
|
615
|
-
const args = [
|
|
616
|
-
config.landstrip.command,
|
|
617
|
-
...(config.landstrip.debug ? ['--debug'] : []),
|
|
618
|
-
'-p',
|
|
619
|
-
policyPath,
|
|
620
|
-
...shellArgs(shell, command),
|
|
621
|
-
];
|
|
639
|
+
function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
|
|
640
|
+
const args = ['-p', policyPath, ...shellArgs(shell, command)];
|
|
622
641
|
|
|
623
|
-
return args.map(shellQuote).join(' ');
|
|
642
|
+
return [binaryPath(), ...args].map(shellQuote).join(' ');
|
|
624
643
|
}
|
|
625
644
|
|
|
626
|
-
function isGeneratedWrappedCommand(
|
|
645
|
+
function isGeneratedWrappedCommand(command: string): boolean {
|
|
627
646
|
return (
|
|
628
|
-
command.startsWith(`${shellQuote(
|
|
647
|
+
command.startsWith(`${shellQuote(binaryPath())} `) &&
|
|
629
648
|
command.includes(` ${shellQuote('-p')} `) &&
|
|
630
649
|
command.includes('opencode-landstrip-')
|
|
631
650
|
);
|
|
@@ -710,10 +729,7 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
710
729
|
const notified = new Set<string>();
|
|
711
730
|
let enabledNotified = false;
|
|
712
731
|
let configuredShell: string | undefined;
|
|
713
|
-
let landstripCheck:
|
|
714
|
-
| { command: string; ok: true; version: string }
|
|
715
|
-
| { command: string; ok: false; reason: string }
|
|
716
|
-
| undefined;
|
|
732
|
+
let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
|
|
717
733
|
|
|
718
734
|
async function notifyOnce(key: string, message: string, variant: ToastVariant): Promise<void> {
|
|
719
735
|
if (notified.has(key)) return;
|
|
@@ -738,38 +754,35 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
738
754
|
.catch(() => undefined);
|
|
739
755
|
}
|
|
740
756
|
|
|
741
|
-
function checkLandstrip(
|
|
742
|
-
if (landstripCheck
|
|
757
|
+
function checkLandstrip(): typeof landstripCheck {
|
|
758
|
+
if (landstripCheck) return landstripCheck;
|
|
743
759
|
|
|
744
760
|
if (!SUPPORTED_PLATFORMS.has(process.platform)) {
|
|
745
761
|
landstripCheck = {
|
|
746
|
-
command: config.landstrip.command,
|
|
747
762
|
ok: false,
|
|
748
763
|
reason: `landstrip sandboxing is not supported on ${process.platform}`,
|
|
749
764
|
};
|
|
750
765
|
return landstripCheck;
|
|
751
766
|
}
|
|
752
767
|
|
|
753
|
-
const version = landstripVersion(
|
|
768
|
+
const version = landstripVersion();
|
|
754
769
|
if (!version) {
|
|
755
770
|
landstripCheck = {
|
|
756
|
-
command: config.landstrip.command,
|
|
757
771
|
ok: false,
|
|
758
|
-
reason: `landstrip was not found.
|
|
772
|
+
reason: `landstrip was not found. Reinstall with: npm install @jarkkojs/landstrip`,
|
|
759
773
|
};
|
|
760
774
|
return landstripCheck;
|
|
761
775
|
}
|
|
762
776
|
|
|
763
777
|
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
764
778
|
landstripCheck = {
|
|
765
|
-
command: config.landstrip.command,
|
|
766
779
|
ok: false,
|
|
767
|
-
reason: `landstrip 0.
|
|
780
|
+
reason: `landstrip 0.9.2 or newer is required; found: ${version}`,
|
|
768
781
|
};
|
|
769
782
|
return landstripCheck;
|
|
770
783
|
}
|
|
771
784
|
|
|
772
|
-
landstripCheck = {
|
|
785
|
+
landstripCheck = { ok: true, version };
|
|
773
786
|
return landstripCheck;
|
|
774
787
|
}
|
|
775
788
|
|
|
@@ -777,7 +790,7 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
777
790
|
const config = loadConfig(directory, optionOverrides);
|
|
778
791
|
if (!config.enabled) return null;
|
|
779
792
|
|
|
780
|
-
const check = checkLandstrip(
|
|
793
|
+
const check = checkLandstrip();
|
|
781
794
|
if (!check?.ok) {
|
|
782
795
|
await notifyOnce(
|
|
783
796
|
`disabled:${check?.reason ?? 'unknown'}`,
|
|
@@ -837,7 +850,7 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
837
850
|
await cleanupBash(callID);
|
|
838
851
|
}
|
|
839
852
|
|
|
840
|
-
if (isGeneratedWrappedCommand(
|
|
853
|
+
if (isGeneratedWrappedCommand(args.command)) {
|
|
841
854
|
if (typeof args.description === 'string')
|
|
842
855
|
args.description = landstripDescription(args.description);
|
|
843
856
|
return;
|
|
@@ -867,7 +880,6 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
867
880
|
|
|
868
881
|
const originalCommand = args.command;
|
|
869
882
|
const wrappedCommand = buildWrappedCommand(
|
|
870
|
-
config,
|
|
871
883
|
policy.path,
|
|
872
884
|
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
873
885
|
originalCommand,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
"ci:check": "npm run check",
|
|
42
42
|
"ci:test": "npm test"
|
|
43
43
|
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@jarkkojs/landstrip": "^0.9.2"
|
|
46
|
+
},
|
|
44
47
|
"devDependencies": {
|
|
45
48
|
"@opencode-ai/plugin": "^1.16.2",
|
|
46
49
|
"@types/node": "^24.0.0",
|