opencode-landstrip 0.3.13 → 0.14.1

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 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/jarkkojs/landstrip).
4
+ [`landstrip`](https://github.com/landstrip/landstrip).
5
5
 
6
6
  ## Install
7
7
 
@@ -26,7 +26,7 @@ manually:
26
26
 
27
27
  `opencode plugin install opencode-landstrip` configures both entrypoints.
28
28
 
29
- This installs `opencode-landstrip` and its `@jarkkojs/landstrip` dependency, which
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
32
  Requires OpenCode `1.17.7` or newer.
@@ -82,5 +82,5 @@ Set `enabled` to `false` in `sandbox.json`, or pass plugin options:
82
82
  `opencode-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
83
83
  information.
84
84
 
85
- The bundled `@jarkkojs/landstrip` package is licensed separately as
85
+ The bundled `@landstrip/landstrip` package is licensed separately as
86
86
  `Apache-2.0 AND LGPL-2.1-or-later`.
package/index.ts CHANGED
@@ -32,14 +32,12 @@ interface LandstripPolicy {
32
32
  filesystem: SandboxFilesystemConfig;
33
33
  }
34
34
 
35
- interface LandstripErrorResponse {
36
- reason: 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
37
- file?: string;
38
- operation?: 'read' | 'write';
39
- program?: string;
40
- type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
41
- source?: string;
42
- }
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> };
43
41
 
44
42
  interface BashSandboxState {
45
43
  originalCommand: string;
@@ -60,40 +58,13 @@ interface SandboxPermissionDecision {
60
58
 
61
59
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
62
60
 
63
- const LANDSTRIP_VERSION = [0, 11, 9] as const;
61
+ const LANDSTRIP_VERSION = [0, 14, 5] as const;
64
62
  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
- ]);
63
+ const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
83
64
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
84
65
 
85
- function isLandstripErrorReason(value: string): value is LandstripErrorResponse['reason'] {
86
- return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorResponse['reason']);
87
- }
88
-
89
- function isLandstripOperation(
90
- value: string,
91
- ): value is NonNullable<LandstripErrorResponse['operation']> {
92
- return LANDSTRIP_OPERATIONS.has(value as NonNullable<LandstripErrorResponse['operation']>);
93
- }
94
-
95
- function isLandstripErrorType(value: string): value is NonNullable<LandstripErrorResponse['type']> {
96
- return LANDSTRIP_ERROR_TYPES.has(value as NonNullable<LandstripErrorResponse['type']>);
66
+ function isLandstripOperation(value: unknown): value is 'read' | 'write' {
67
+ return typeof value === 'string' && LANDSTRIP_OPERATIONS.has(value as 'read' | 'write');
97
68
  }
98
69
 
99
70
  function expandPath(filePath: string, baseDirectory: string): string {
@@ -130,6 +101,38 @@ function canonicalizePath(filePath: string, baseDirectory: string): string {
130
101
  }
131
102
  }
132
103
 
104
+ /**
105
+ * Translates an absolute glob pattern to a regular expression using standard
106
+ * path semantics: `**` crosses directory boundaries (and `**​/` may match zero
107
+ * segments), while a single `*` is confined to one path segment.
108
+ */
109
+ function globToRegExp(globPattern: string): RegExp {
110
+ let regex = '';
111
+
112
+ for (let i = 0; i < globPattern.length; i++) {
113
+ const char = globPattern[i];
114
+ if (char === '*') {
115
+ if (globPattern[i + 1] === '*') {
116
+ i++;
117
+ if (globPattern[i + 1] === '/') {
118
+ i++;
119
+ regex += '(?:.*/)?';
120
+ } else {
121
+ regex += '.*';
122
+ }
123
+ } else {
124
+ regex += '[^/]*';
125
+ }
126
+ } else if (/[.+^${}()|[\]\\]/.test(char)) {
127
+ regex += `\\${char}`;
128
+ } else {
129
+ regex += char;
130
+ }
131
+ }
132
+
133
+ return new RegExp(`^${regex}$`);
134
+ }
135
+
133
136
  function matchesPattern(filePath: string, patterns: string[], baseDirectory: string): boolean {
134
137
  const abs = canonicalizePath(filePath, baseDirectory);
135
138
 
@@ -139,8 +142,7 @@ function matchesPattern(filePath: string, patterns: string[], baseDirectory: str
139
142
  : canonicalizePath(pattern, baseDirectory);
140
143
 
141
144
  if (pattern.includes('*')) {
142
- const escaped = absPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
143
- return new RegExp(`^${escaped}$`).test(abs);
145
+ return globToRegExp(absPattern).test(abs);
144
146
  }
145
147
 
146
148
  const sep = absPattern.endsWith('/') ? '' : '/';
@@ -242,13 +244,16 @@ function extractBlockedPath(
242
244
  );
243
245
  if (match) return normalizeBlockedPath(match[1], baseDirectory);
244
246
 
245
- // Landstrip structured error format with file field
247
+ // Landstrip structured trap format carrying a denied path
246
248
  const landstripErrors = parseLandstripErrors(output);
247
- for (const error of landstripErrors) {
248
- if (error.file) return normalizeBlockedPath(error.file, baseDirectory);
249
+ for (const trap of landstripErrors) {
250
+ if (trap.kind === 'Filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
251
+ if (trap.kind === 'Internal' && trap.fields.file) {
252
+ return normalizeBlockedPath(trap.fields.file, baseDirectory);
253
+ }
249
254
  }
250
255
 
251
- // If landstrip reported an error but without a file field, try to
256
+ // If landstrip reported a trap but without a path, try to
252
257
  // extract the blocked path from the command itself
253
258
  if (landstripErrors.length > 0 && command) {
254
259
  for (const candidate of extractCandidatePaths(command)) {
@@ -297,10 +302,6 @@ function evaluateReadPermission(
297
302
  ): SandboxPermissionDecision {
298
303
  const filePath = canonicalizePath(path, baseDirectory);
299
304
 
300
- if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
301
- return { status: 'allow', kind: 'read', resource: filePath, message: '' };
302
- }
303
-
304
305
  if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
305
306
  return {
306
307
  status: 'deny',
@@ -310,6 +311,10 @@ function evaluateReadPermission(
310
311
  };
311
312
  }
312
313
 
314
+ if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
315
+ return { status: 'allow', kind: 'read', resource: filePath, message: '' };
316
+ }
317
+
313
318
  return {
314
319
  status: 'ask',
315
320
  kind: 'read',
@@ -326,10 +331,6 @@ function evaluateWritePermission(
326
331
  ): SandboxPermissionDecision {
327
332
  const filePath = canonicalizePath(path, baseDirectory);
328
333
 
329
- if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
330
- return { status: 'allow', kind: 'write', resource: filePath, message: '' };
331
- }
332
-
333
334
  if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
334
335
  return {
335
336
  status: 'deny',
@@ -339,6 +340,10 @@ function evaluateWritePermission(
339
340
  };
340
341
  }
341
342
 
343
+ if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
344
+ return { status: 'allow', kind: 'write', resource: filePath, message: '' };
345
+ }
346
+
342
347
  return {
343
348
  status: 'ask',
344
349
  kind: 'write',
@@ -351,7 +356,7 @@ function evaluateDomainPermission(
351
356
  domain: string,
352
357
  config: SandboxConfig,
353
358
  ): SandboxPermissionDecision {
354
- if (config.network.allowNetwork || domainMatchesAny(domain, config.network.allowedDomains)) {
359
+ if (config.network.allowNetwork) {
355
360
  return { status: 'allow', kind: 'domain', resource: domain, message: '' };
356
361
  }
357
362
 
@@ -364,6 +369,10 @@ function evaluateDomainPermission(
364
369
  };
365
370
  }
366
371
 
372
+ if (domainMatchesAny(domain, config.network.allowedDomains)) {
373
+ return { status: 'allow', kind: 'domain', resource: domain, message: '' };
374
+ }
375
+
367
376
  return {
368
377
  status: 'ask',
369
378
  kind: 'domain',
@@ -396,63 +405,94 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
396
405
  return true;
397
406
  }
398
407
 
399
- function parseLandstripErrors(output: string): LandstripErrorResponse[] {
400
- const errors: LandstripErrorResponse[] = [];
401
-
402
- for (const block of output.trim().split(/\n\n+/)) {
403
- const fields: Record<string, string> = {};
404
-
405
- for (const line of block.split('\n')) {
406
- const colonIndex = line.indexOf(':');
407
- if (colonIndex === -1) continue;
408
- const key = line.slice(0, colonIndex).trim();
409
- const value = line.slice(colonIndex + 1).trim();
410
- if (key.length > 0 && value.length > 0) fields[key] = value;
408
+ function decodeLandstripTrap(value: unknown): LandstripTrap | null {
409
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
410
+ const entries = Object.entries(value as Record<string, unknown>);
411
+ if (entries.length !== 1) return null;
412
+ const [kind, payload] = entries[0];
413
+
414
+ switch (kind) {
415
+ case 'Filesystem': {
416
+ if (!Array.isArray(payload)) return null;
417
+ const [operation, path, mechanism] = payload;
418
+ if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
419
+ return { kind, operation, path, mechanism: typeof mechanism === 'string' ? mechanism : '' };
411
420
  }
412
-
413
- if (fields.reason && isLandstripErrorReason(fields.reason)) {
414
- const error: LandstripErrorResponse = {
415
- reason: fields.reason,
416
- };
417
-
418
- if (fields.file) error.file = fields.file;
419
- if (fields.operation && isLandstripOperation(fields.operation)) {
420
- error.operation = fields.operation;
421
+ case 'Network': {
422
+ if (!Array.isArray(payload)) return null;
423
+ const [operation, target, mechanism] = payload;
424
+ if (typeof operation !== 'string' || typeof target !== 'string') return null;
425
+ return { kind, operation, target, mechanism: typeof mechanism === 'string' ? mechanism : '' };
426
+ }
427
+ case 'Launch': {
428
+ if (!Array.isArray(payload)) return null;
429
+ const [program, message] = payload;
430
+ if (typeof program !== 'string') return null;
431
+ return { kind, program, message: typeof message === 'string' ? message : '' };
432
+ }
433
+ case 'Usage': {
434
+ if (typeof payload !== 'string') return null;
435
+ return { kind, message: payload };
436
+ }
437
+ case 'Internal': {
438
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null;
439
+ const fields: Record<string, string> = {};
440
+ for (const [key, val] of Object.entries(payload as Record<string, unknown>)) {
441
+ fields[key] = typeof val === 'string' ? val : JSON.stringify(val);
421
442
  }
422
- if (fields.program) error.program = fields.program;
423
- if (fields.source) error.source = fields.source;
443
+ return { kind, fields };
444
+ }
445
+ default:
446
+ return null;
447
+ }
448
+ }
424
449
 
425
- if (fields.type && isLandstripErrorType(fields.type)) error.type = fields.type;
450
+ function parseLandstripErrors(output: string): LandstripTrap[] {
451
+ const traps: LandstripTrap[] = [];
426
452
 
427
- errors.push(error);
453
+ for (const line of output.split('\n')) {
454
+ const trimmed = line.trim();
455
+ if (trimmed.length === 0 || trimmed[0] !== '{') continue;
456
+
457
+ let parsed: unknown;
458
+ try {
459
+ parsed = JSON.parse(trimmed);
460
+ } catch {
461
+ continue;
428
462
  }
463
+
464
+ const trap = decodeLandstripTrap(parsed);
465
+ if (trap) traps.push(trap);
429
466
  }
430
467
 
431
- return errors;
468
+ return traps;
432
469
  }
433
470
 
434
- function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
435
- return errors
436
- .map((err) => {
437
- const parts: string[] = [`landstrip: ${err.reason}`];
438
-
439
- if (err.file) {
440
- parts.push(` (${err.file})`);
441
- }
442
- if (err.operation) {
443
- parts.push(` ${err.operation}`);
444
- }
445
- if (err.program) {
446
- parts.push(` ${err.program}`);
447
- }
448
- if (err.type) {
449
- parts.push(`:${err.type}`);
450
- }
451
- if (err.source) parts.push(`: ${err.source}`);
471
+ function formatLandstripTrap(trap: LandstripTrap): string {
472
+ switch (trap.kind) {
473
+ case 'Filesystem':
474
+ return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
475
+ trap.mechanism ? ` [${trap.mechanism}]` : ''
476
+ }`;
477
+ case 'Network':
478
+ return `landstrip: network ${trap.operation} denied (${trap.target})${
479
+ trap.mechanism ? ` [${trap.mechanism}]` : ''
480
+ }`;
481
+ case 'Launch':
482
+ return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
483
+ case 'Usage':
484
+ return `landstrip: usage error: ${trap.message}`;
485
+ case 'Internal': {
486
+ const detail = Object.entries(trap.fields)
487
+ .map(([key, val]) => `${key}: ${val}`)
488
+ .join(', ');
489
+ return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
490
+ }
491
+ }
492
+ }
452
493
 
453
- return parts.join('');
454
- })
455
- .join('\n');
494
+ function formatLandstripErrors(traps: LandstripTrap[]): string {
495
+ return traps.map(formatLandstripTrap).join('\n');
456
496
  }
457
497
 
458
498
  function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
@@ -542,10 +582,15 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
542
582
  return;
543
583
  }
544
584
 
585
+ let connected = false;
545
586
  const upstream = connectNet(endpoint.port, endpoint.host, () => {
587
+ connected = true;
546
588
  client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
547
589
  pipeSockets(client, upstream, rest);
548
590
  });
591
+ upstream.on('error', () => {
592
+ if (!connected) denyProxyRequest(client, '502 Bad Gateway');
593
+ });
549
594
  }
550
595
 
551
596
  async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
@@ -584,10 +629,15 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
584
629
  const rewrittenHeader = lines
585
630
  .filter((line) => !line.toLowerCase().startsWith('proxy-connection:'))
586
631
  .join('\r\n');
632
+ let connected = false;
587
633
  const upstream = connectNet(port, url.hostname, () => {
634
+ connected = true;
588
635
  upstream.write(`${rewrittenHeader}\r\n\r\n`);
589
636
  pipeSockets(client, upstream, rest);
590
637
  });
638
+ upstream.on('error', () => {
639
+ if (!connected) denyProxyRequest(client, '502 Bad Gateway');
640
+ });
591
641
  }
592
642
 
593
643
  function handleClient(client: Socket): void {
@@ -948,7 +998,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
948
998
  if (!version) {
949
999
  landstripCheck = {
950
1000
  ok: false,
951
- reason: `landstrip was not found. Reinstall with: npm install @jarkkojs/landstrip`,
1001
+ reason: `landstrip was not found. Reinstall with: npm install @landstrip/landstrip`,
952
1002
  };
953
1003
  return landstripCheck;
954
1004
  }
package/landstrip.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- declare module '@jarkkojs/landstrip' {
1
+ declare module '@landstrip/landstrip' {
2
2
  function binaryPath(): string;
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.3.13",
3
+ "version": "0.14.1",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -11,7 +11,7 @@
11
11
  "license": "MIT",
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "git+https://github.com/jarkkojs/opencode-landstrip.git"
14
+ "url": "git+https://github.com/landstrip/opencode-landstrip.git"
15
15
  },
16
16
  "files": [
17
17
  "index.ts",
@@ -49,7 +49,7 @@
49
49
  "ci:test": "npm test"
50
50
  },
51
51
  "dependencies": {
52
- "@jarkkojs/landstrip": "^0.11.11"
52
+ "@landstrip/landstrip": "^0.14.5"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@opencode-ai/plugin": "^1.17.7",
package/shared.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
- import { binaryPath } from '@jarkkojs/landstrip';
4
+ import { binaryPath } from '@landstrip/landstrip';
5
5
 
6
6
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
@@ -54,11 +54,11 @@ export const DEFAULT_CONFIG: SandboxConfig = {
54
54
  };
55
55
 
56
56
  const LANDSTRIP_PACKAGE_NAMES = new Set([
57
- '@jarkkojs/landstrip',
58
- '@jarkkojs/landstrip-darwin-arm64',
59
- '@jarkkojs/landstrip-darwin-x64',
60
- '@jarkkojs/landstrip-linux-x64',
61
- '@jarkkojs/landstrip-win32-x64',
57
+ '@landstrip/landstrip',
58
+ '@landstrip/landstrip-darwin-arm64',
59
+ '@landstrip/landstrip-darwin-x64',
60
+ '@landstrip/landstrip-linux-x64',
61
+ '@landstrip/landstrip-win32-x64',
62
62
  ]);
63
63
 
64
64
  export function isRecord(value: unknown): value is Record<string, unknown> {
@@ -227,7 +227,7 @@ export function landstripBinaryPath(): string {
227
227
  }
228
228
 
229
229
  throw new Error(
230
- `Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
230
+ `Refusing to use landstrip binary outside official @landstrip/landstrip packages: ${filePath}`,
231
231
  );
232
232
  }
233
233