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.
Files changed (4) hide show
  1. package/README.md +5 -11
  2. package/index.ts +116 -67
  3. package/package.json +10 -5
  4. 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, 8, 3] as const;
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(command: string): string | null {
366
- const result = spawnSync(command, ['--version'], { encoding: 'utf-8' });
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
- config: SandboxConfig,
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(config: SandboxConfig): typeof landstripCheck {
728
- if (landstripCheck?.command === config.landstrip.command) return 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(config.landstrip.command);
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. Install it with: cargo install landstrip`,
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.8.3 or newer is required; found: ${version}`,
780
+ reason: `landstrip 0.9.2 or newer is required; found: ${version}`,
754
781
  };
755
782
  return landstripCheck;
756
783
  }
757
784
 
758
- landstripCheck = { command: config.landstrip.command, ok: true, version };
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(config);
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
- await cleanupBash(callID);
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 = buildWrappedCommand(
844
- config,
845
- policy.path,
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.1.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
- "all": "npm run fmt && npm run lint && npm run check",
38
- "ci:fmt": "oxfmt --check index.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md",
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": [".", "~/.config/opencode", "~/.local", "~/.cargo"],
22
- "allowWrite": [".", "/tmp", "~/.cargo", "~/.rustup"],
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
  }