opencode-landstrip 0.1.0 → 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 +116 -67
- package/package.json +10 -5
- package/sandbox.json +34 -2
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,14 +50,24 @@ 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 {
|
|
69
|
+
originalCommand: string;
|
|
70
|
+
wrappedCommand: string;
|
|
65
71
|
policyDir: string;
|
|
66
72
|
port: number;
|
|
67
73
|
stop: () => Promise<void>;
|
|
@@ -69,7 +75,7 @@ interface BashSandboxState {
|
|
|
69
75
|
|
|
70
76
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
71
77
|
|
|
72
|
-
const LANDSTRIP_VERSION = [0,
|
|
78
|
+
const LANDSTRIP_VERSION = [0, 9, 2] as const;
|
|
73
79
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
74
80
|
|
|
75
81
|
const DEFAULT_CONFIG: SandboxConfig = {
|
|
@@ -97,14 +103,10 @@ const DEFAULT_CONFIG: SandboxConfig = {
|
|
|
97
103
|
},
|
|
98
104
|
filesystem: {
|
|
99
105
|
denyRead: ['/Users', '/home'],
|
|
100
|
-
allowRead: ['.', '~/.config/opencode', '~/.local', '~/.cargo'],
|
|
106
|
+
allowRead: ['.', '~/.config/opencode', '~/.config/git', '~/.gitconfig', '~/.local', '~/.cargo'],
|
|
101
107
|
allowWrite: ['.', '/tmp'],
|
|
102
108
|
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
103
109
|
},
|
|
104
|
-
landstrip: {
|
|
105
|
-
command: 'landstrip',
|
|
106
|
-
debug: false,
|
|
107
|
-
},
|
|
108
110
|
};
|
|
109
111
|
|
|
110
112
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -156,15 +158,6 @@ function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemCon
|
|
|
156
158
|
return config;
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
function normalizeLandstripConfig(value: unknown): Partial<LandstripConfig> | undefined {
|
|
160
|
-
if (!isRecord(value)) return undefined;
|
|
161
|
-
|
|
162
|
-
const config: Partial<LandstripConfig> = {};
|
|
163
|
-
if (typeof value.command === 'string') config.command = value.command;
|
|
164
|
-
if (typeof value.debug === 'boolean') config.debug = value.debug;
|
|
165
|
-
return config;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
161
|
function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
169
162
|
if (!isRecord(value)) return {};
|
|
170
163
|
|
|
@@ -177,9 +170,6 @@ function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
|
177
170
|
const filesystem = normalizeFilesystemConfig(value.filesystem);
|
|
178
171
|
if (filesystem) config.filesystem = filesystem;
|
|
179
172
|
|
|
180
|
-
const landstrip = normalizeLandstripConfig(value.landstrip);
|
|
181
|
-
if (landstrip) config.landstrip = landstrip;
|
|
182
|
-
|
|
183
173
|
return config;
|
|
184
174
|
}
|
|
185
175
|
|
|
@@ -199,10 +189,6 @@ function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): Sand
|
|
|
199
189
|
...base.filesystem,
|
|
200
190
|
...overrides.filesystem,
|
|
201
191
|
},
|
|
202
|
-
landstrip: {
|
|
203
|
-
...base.landstrip,
|
|
204
|
-
...overrides.landstrip,
|
|
205
|
-
},
|
|
206
192
|
};
|
|
207
193
|
}
|
|
208
194
|
|
|
@@ -362,8 +348,8 @@ function firstBlockedDomain(
|
|
|
362
348
|
return null;
|
|
363
349
|
}
|
|
364
350
|
|
|
365
|
-
function landstripVersion(
|
|
366
|
-
const result = spawnSync(
|
|
351
|
+
function landstripVersion(): string | null {
|
|
352
|
+
const result = spawnSync(binaryPath(), ['--version'], { encoding: 'utf-8' });
|
|
367
353
|
if (result.status !== 0) return null;
|
|
368
354
|
return result.stdout.trim();
|
|
369
355
|
}
|
|
@@ -386,6 +372,52 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
386
372
|
return true;
|
|
387
373
|
}
|
|
388
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
|
+
|
|
389
421
|
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
390
422
|
const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
391
423
|
if (bracketMatch) {
|
|
@@ -604,21 +636,22 @@ function shellArgs(shell: string, command: string): string[] {
|
|
|
604
636
|
return [shell, '-lc', command];
|
|
605
637
|
}
|
|
606
638
|
|
|
607
|
-
function buildWrappedCommand(
|
|
608
|
-
|
|
609
|
-
policyPath: string,
|
|
610
|
-
shell: string,
|
|
611
|
-
command: string,
|
|
612
|
-
): string {
|
|
613
|
-
const args = [
|
|
614
|
-
config.landstrip.command,
|
|
615
|
-
...(config.landstrip.debug ? ['--debug'] : []),
|
|
616
|
-
'-p',
|
|
617
|
-
policyPath,
|
|
618
|
-
...shellArgs(shell, command),
|
|
619
|
-
];
|
|
639
|
+
function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
|
|
640
|
+
const args = ['-p', policyPath, ...shellArgs(shell, command)];
|
|
620
641
|
|
|
621
|
-
return args.map(shellQuote).join(' ');
|
|
642
|
+
return [binaryPath(), ...args].map(shellQuote).join(' ');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function isGeneratedWrappedCommand(command: string): boolean {
|
|
646
|
+
return (
|
|
647
|
+
command.startsWith(`${shellQuote(binaryPath())} `) &&
|
|
648
|
+
command.includes(` ${shellQuote('-p')} `) &&
|
|
649
|
+
command.includes('opencode-landstrip-')
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function landstripDescription(description: string): string {
|
|
654
|
+
return description.endsWith(' (landstrip)') ? description : `${description} (landstrip)`;
|
|
622
655
|
}
|
|
623
656
|
|
|
624
657
|
function getToolPath(args: Record<string, unknown>): string | undefined {
|
|
@@ -696,10 +729,7 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
696
729
|
const notified = new Set<string>();
|
|
697
730
|
let enabledNotified = false;
|
|
698
731
|
let configuredShell: string | undefined;
|
|
699
|
-
let landstripCheck:
|
|
700
|
-
| { command: string; ok: true; version: string }
|
|
701
|
-
| { command: string; ok: false; reason: string }
|
|
702
|
-
| undefined;
|
|
732
|
+
let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
|
|
703
733
|
|
|
704
734
|
async function notifyOnce(key: string, message: string, variant: ToastVariant): Promise<void> {
|
|
705
735
|
if (notified.has(key)) return;
|
|
@@ -724,38 +754,35 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
724
754
|
.catch(() => undefined);
|
|
725
755
|
}
|
|
726
756
|
|
|
727
|
-
function checkLandstrip(
|
|
728
|
-
if (landstripCheck
|
|
757
|
+
function checkLandstrip(): typeof landstripCheck {
|
|
758
|
+
if (landstripCheck) return landstripCheck;
|
|
729
759
|
|
|
730
760
|
if (!SUPPORTED_PLATFORMS.has(process.platform)) {
|
|
731
761
|
landstripCheck = {
|
|
732
|
-
command: config.landstrip.command,
|
|
733
762
|
ok: false,
|
|
734
763
|
reason: `landstrip sandboxing is not supported on ${process.platform}`,
|
|
735
764
|
};
|
|
736
765
|
return landstripCheck;
|
|
737
766
|
}
|
|
738
767
|
|
|
739
|
-
const version = landstripVersion(
|
|
768
|
+
const version = landstripVersion();
|
|
740
769
|
if (!version) {
|
|
741
770
|
landstripCheck = {
|
|
742
|
-
command: config.landstrip.command,
|
|
743
771
|
ok: false,
|
|
744
|
-
reason: `landstrip was not found.
|
|
772
|
+
reason: `landstrip was not found. Reinstall with: npm install @jarkkojs/landstrip`,
|
|
745
773
|
};
|
|
746
774
|
return landstripCheck;
|
|
747
775
|
}
|
|
748
776
|
|
|
749
777
|
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
750
778
|
landstripCheck = {
|
|
751
|
-
command: config.landstrip.command,
|
|
752
779
|
ok: false,
|
|
753
|
-
reason: `landstrip 0.
|
|
780
|
+
reason: `landstrip 0.9.2 or newer is required; found: ${version}`,
|
|
754
781
|
};
|
|
755
782
|
return landstripCheck;
|
|
756
783
|
}
|
|
757
784
|
|
|
758
|
-
landstripCheck = {
|
|
785
|
+
landstripCheck = { ok: true, version };
|
|
759
786
|
return landstripCheck;
|
|
760
787
|
}
|
|
761
788
|
|
|
@@ -763,7 +790,7 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
763
790
|
const config = loadConfig(directory, optionOverrides);
|
|
764
791
|
if (!config.enabled) return null;
|
|
765
792
|
|
|
766
|
-
const check = checkLandstrip(
|
|
793
|
+
const check = checkLandstrip();
|
|
767
794
|
if (!check?.ok) {
|
|
768
795
|
await notifyOnce(
|
|
769
796
|
`disabled:${check?.reason ?? 'unknown'}`,
|
|
@@ -810,7 +837,24 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
810
837
|
config: SandboxConfig,
|
|
811
838
|
): Promise<void> {
|
|
812
839
|
if (typeof args.command !== 'string') return;
|
|
813
|
-
|
|
840
|
+
|
|
841
|
+
const existing = activeBash.get(callID);
|
|
842
|
+
if (existing) {
|
|
843
|
+
if (args.command === existing.originalCommand || args.command === existing.wrappedCommand) {
|
|
844
|
+
args.command = existing.wrappedCommand;
|
|
845
|
+
if (typeof args.description === 'string')
|
|
846
|
+
args.description = landstripDescription(args.description);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
await cleanupBash(callID);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (isGeneratedWrappedCommand(args.command)) {
|
|
854
|
+
if (typeof args.description === 'string')
|
|
855
|
+
args.description = landstripDescription(args.description);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
814
858
|
|
|
815
859
|
const blockedDomain = firstBlockedDomain(args.command, config);
|
|
816
860
|
if (blockedDomain) {
|
|
@@ -834,19 +878,24 @@ export default (async ({ client, directory }: PluginInput, options?: PluginOptio
|
|
|
834
878
|
throw error;
|
|
835
879
|
}
|
|
836
880
|
|
|
881
|
+
const originalCommand = args.command;
|
|
882
|
+
const wrappedCommand = buildWrappedCommand(
|
|
883
|
+
policy.path,
|
|
884
|
+
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
885
|
+
originalCommand,
|
|
886
|
+
);
|
|
887
|
+
|
|
837
888
|
activeBash.set(callID, {
|
|
889
|
+
originalCommand,
|
|
890
|
+
wrappedCommand,
|
|
838
891
|
policyDir: policy.dir,
|
|
839
892
|
port: proxy.port,
|
|
840
893
|
stop: proxy.stop,
|
|
841
894
|
});
|
|
842
895
|
|
|
843
|
-
args.command =
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
847
|
-
args.command,
|
|
848
|
-
);
|
|
849
|
-
if (typeof args.description === 'string') args.description = `${args.description} (landstrip)`;
|
|
896
|
+
args.command = wrappedCommand;
|
|
897
|
+
if (typeof args.description === 'string')
|
|
898
|
+
args.description = landstripDescription(args.description);
|
|
850
899
|
}
|
|
851
900
|
|
|
852
901
|
const hooks: Hooks = {
|
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",
|
|
@@ -31,13 +31,18 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"fmt": "oxfmt index.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md",
|
|
34
|
+
"fmt": "oxfmt index.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
|
|
35
35
|
"lint": "oxlint index.ts",
|
|
36
36
|
"check": "tsc --noEmit",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
37
|
+
"test": "node --test test/*.test.mjs",
|
|
38
|
+
"all": "npm run fmt && npm run lint && npm run check && npm test",
|
|
39
|
+
"ci:fmt": "oxfmt --check index.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
|
|
39
40
|
"ci:lint": "npm run lint",
|
|
40
|
-
"ci:check": "npm run check"
|
|
41
|
+
"ci:check": "npm run check",
|
|
42
|
+
"ci:test": "npm test"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@jarkkojs/landstrip": "^0.9.2"
|
|
41
46
|
},
|
|
42
47
|
"devDependencies": {
|
|
43
48
|
"@opencode-ai/plugin": "^1.16.2",
|
package/sandbox.json
CHANGED
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
"allowedDomains": [
|
|
8
8
|
"github.com",
|
|
9
9
|
"*.github.com",
|
|
10
|
+
"api.github.com",
|
|
10
11
|
"raw.githubusercontent.com",
|
|
12
|
+
"objects.githubusercontent.com",
|
|
13
|
+
"codeload.github.com",
|
|
11
14
|
"registry.npmjs.org",
|
|
15
|
+
"npmjs.org",
|
|
12
16
|
"*.npmjs.org",
|
|
17
|
+
"nodejs.org",
|
|
18
|
+
"*.nodejs.org",
|
|
13
19
|
"crates.io",
|
|
14
20
|
"*.crates.io",
|
|
15
21
|
"static.crates.io"
|
|
@@ -18,8 +24,34 @@
|
|
|
18
24
|
},
|
|
19
25
|
"filesystem": {
|
|
20
26
|
"denyRead": ["/home"],
|
|
21
|
-
"allowRead": [
|
|
22
|
-
|
|
27
|
+
"allowRead": [
|
|
28
|
+
".",
|
|
29
|
+
"/tmp",
|
|
30
|
+
"/var/tmp",
|
|
31
|
+
"/dev/null",
|
|
32
|
+
"~/.config/opencode",
|
|
33
|
+
"~/.config/git",
|
|
34
|
+
"~/.gitconfig",
|
|
35
|
+
"~/.local",
|
|
36
|
+
"~/.cargo",
|
|
37
|
+
"~/.rustup",
|
|
38
|
+
"~/.npm",
|
|
39
|
+
"~/.cache",
|
|
40
|
+
"~/.bun",
|
|
41
|
+
"~/.node-gyp"
|
|
42
|
+
],
|
|
43
|
+
"allowWrite": [
|
|
44
|
+
".",
|
|
45
|
+
"/tmp",
|
|
46
|
+
"/var/tmp",
|
|
47
|
+
"/dev/null",
|
|
48
|
+
"~/.cargo",
|
|
49
|
+
"~/.rustup",
|
|
50
|
+
"~/.npm",
|
|
51
|
+
"~/.cache",
|
|
52
|
+
"~/.bun",
|
|
53
|
+
"~/.node-gyp"
|
|
54
|
+
],
|
|
23
55
|
"denyWrite": [".env", ".env.*", "*.pem", "*.key"]
|
|
24
56
|
}
|
|
25
57
|
}
|