opencode-landstrip 0.18.33 → 0.18.34

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 (3) hide show
  1. package/index.ts +5 -23
  2. package/package.json +2 -2
  3. package/shared.ts +33 -163
package/index.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  isIP,
16
16
  type Socket,
17
17
  } from 'node:net';
18
- import { homedir, tmpdir } from 'node:os';
18
+ import { tmpdir } from 'node:os';
19
19
  import { basename, dirname, isAbsolute, join, parse, resolve, sep } from 'node:path';
20
20
  import { URL } from 'node:url';
21
21
 
@@ -23,8 +23,11 @@ import {
23
23
  type LandstripTrap,
24
24
  type SandboxConfig,
25
25
  type SandboxFilesystemConfig,
26
+ allowsAllDomains,
26
27
  controlResponseLine,
27
28
  decodeLandstripTrap,
29
+ domainMatchesAny,
30
+ expandHomePath,
28
31
  extractDomainsFromCommand,
29
32
  formatLandstripTraps,
30
33
  getConfigPaths,
@@ -104,7 +107,7 @@ for (const [network, prefix] of [
104
107
  }
105
108
 
106
109
  function expandPath(filePath: string, baseDirectory: string): string {
107
- const expanded = filePath.replace(/^~(?=$|[/])/, homedir());
110
+ const expanded = expandHomePath(filePath);
108
111
  return resolve(isAbsolute(expanded) ? expanded : join(baseDirectory, expanded));
109
112
  }
110
113
 
@@ -242,27 +245,6 @@ function resolveFilesystemConfig(
242
245
  };
243
246
  }
244
247
 
245
- function domainMatchesPattern(domain: string, pattern: string): boolean {
246
- const normalizedDomain = domain.toLowerCase();
247
- const normalizedPattern = pattern.toLowerCase();
248
-
249
- if (normalizedPattern === '*') return true;
250
- if (normalizedPattern.startsWith('*.')) {
251
- const base = normalizedPattern.slice(2);
252
- return normalizedDomain === base || normalizedDomain.endsWith(`.${base}`);
253
- }
254
-
255
- return normalizedDomain === normalizedPattern;
256
- }
257
-
258
- function domainMatchesAny(domain: string, patterns: string[]): boolean {
259
- return patterns.some((pattern) => domainMatchesPattern(domain, pattern));
260
- }
261
-
262
- function allowsAllDomains(allowedDomains: string[]): boolean {
263
- return allowedDomains.includes('*');
264
- }
265
-
266
248
  function isDomainAllowed(domain: string, config: SandboxConfig): boolean {
267
249
  return (
268
250
  config.network.allowNetwork ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.18.33",
3
+ "version": "0.18.34",
4
4
  "description": "OS-level AI command sandboxing for OpenCode with Landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -48,7 +48,7 @@
48
48
  "ci:test": "npm test"
49
49
  },
50
50
  "dependencies": {
51
- "@landstrip/landstrip": "^0.18.33",
51
+ "@landstrip/landstrip": "^0.18.34",
52
52
  "@opentui/solid": ">=0.3.4",
53
53
  "solid-js": "1.9.12"
54
54
  },
package/shared.ts CHANGED
@@ -7,6 +7,38 @@ import {
7
7
  type LandstripTrap,
8
8
  } from '@landstrip/landstrip';
9
9
 
10
+ import {
11
+ allowsAllDomains,
12
+ controlResponseLine,
13
+ decodeLandstripTrap,
14
+ domainMatchesAny,
15
+ domainMatchesPattern,
16
+ expandHomePath,
17
+ formatLandstripTrap,
18
+ formatLandstripTraps,
19
+ isRecord,
20
+ parseLandstripTraps,
21
+ pathUnderDirectory,
22
+ sessionAllows,
23
+ sessionScopeFor,
24
+ } from '@landstrip/landstrip/shared';
25
+
26
+ export {
27
+ allowsAllDomains,
28
+ controlResponseLine,
29
+ decodeLandstripTrap,
30
+ domainMatchesAny,
31
+ domainMatchesPattern,
32
+ expandHomePath,
33
+ formatLandstripTrap,
34
+ formatLandstripTraps,
35
+ isRecord,
36
+ parseLandstripTraps,
37
+ pathUnderDirectory,
38
+ sessionAllows,
39
+ sessionScopeFor,
40
+ };
41
+
10
42
  // Re-exported so index.ts/tui.ts can import the trap/response types from this
11
43
  // module alongside the parsing functions that produce/consume them.
12
44
  export type { LandstripControlResponse, LandstripTrap };
@@ -15,7 +47,7 @@ import { createHash } from 'node:crypto';
15
47
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
16
48
  import { homedir, tmpdir } from 'node:os';
17
49
  import { fileURLToPath } from 'node:url';
18
- import { dirname, isAbsolute, join, relative, sep } from 'node:path';
50
+ import { dirname, join } from 'node:path';
19
51
 
20
52
  export interface SandboxFilesystemConfig {
21
53
  denyRead: string[];
@@ -57,62 +89,6 @@ const LANDSTRIP_PACKAGE_NAMES = new Set([
57
89
  '@landstrip/landstrip-win32-arm64',
58
90
  ]);
59
91
 
60
- // Breadth-first filesystem approval: a held read/write under a directory tree
61
- // is approved for the broadest reasonable ancestor (e.g. `~/.cargo`, not each
62
- // subcrate file), so a single approval covers sibling files under the same tree.
63
- export function pathUnderDirectory(filePath: string, dir: string): boolean {
64
- const child = relative(dir, filePath);
65
- return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`));
66
- }
67
-
68
- export function sessionAllows(prefixes: Set<string>, filePath: string): boolean {
69
- for (const prefix of prefixes) {
70
- if (pathUnderDirectory(filePath, prefix)) return true;
71
- }
72
- return false;
73
- }
74
-
75
- // The broadest ancestor worth approving in one click: the immediate child of
76
- // `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
77
- // for paths under it, otherwise the containing directory. When the file sits
78
- // directly on a boundary (so the only ancestor is `$HOME` itself, which would
79
- // over-broaden), fall back to the exact file so nothing widens silently.
80
- export function sessionScopeFor(filePath: string, baseDirectory: string): string {
81
- const dir = dirname(filePath);
82
- const home = homedir();
83
- const homeBoundaries = new Set<string>([home]);
84
- try {
85
- homeBoundaries.add(realpathSync.native(home));
86
- } catch {
87
- // $HOME not resolvable — fall back to the raw value only.
88
- }
89
-
90
- for (const boundary of homeBoundaries) {
91
- if (pathUnderDirectory(dir, boundary)) {
92
- const first = relative(boundary, dir).split(sep)[0];
93
- if (!first) return filePath;
94
- return join(boundary, first);
95
- }
96
- }
97
-
98
- const projectBoundaries = new Set<string>([baseDirectory]);
99
- try {
100
- projectBoundaries.add(realpathSync.native(baseDirectory));
101
- } catch {
102
- // Project directory not resolvable — fall back to the raw value only.
103
- }
104
-
105
- for (const boundary of projectBoundaries) {
106
- if (pathUnderDirectory(dir, boundary)) return boundary;
107
- }
108
-
109
- return dir;
110
- }
111
-
112
- export function isRecord(value: unknown): value is Record<string, unknown> {
113
- return typeof value === 'object' && value !== null && !Array.isArray(value);
114
- }
115
-
116
92
  export function list(values: string[]): string {
117
93
  return values.join(', ') || '(none)';
118
94
  }
@@ -404,112 +380,6 @@ export function updateForPermission(
404
380
  return null;
405
381
  }
406
382
 
407
- // Landstrip emits one JSON trap per line on its trap fd/file. The `state` field
408
- // distinguishes a terminal `info` trap from a `query` trap that holds a syscall
409
- // pending until the host answers with the trap's `query_id`. Parsing lives here
410
- // so the server hook and the TUI socket handler decode identically.
411
- const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
412
-
413
- function isLandstripOperation(value: unknown): value is 'read' | 'write' {
414
- return typeof value === 'string' && LANDSTRIP_OPERATIONS.has(value as 'read' | 'write');
415
- }
416
-
417
- // The declarations landstrip ships are compile-time only, so a runtime guard is
418
- // still required — but it only has to validate the fields actually read below.
419
- // Deliberately does not validate `state`: a missing or unknown state degrades to
420
- // "informational", the safe direction. `query_id` must be a string on both
421
- // filesystem and network traps — landstrip < 0.17 sent a number here, and a
422
- // numeric id fails landstrip's own deserializer when echoed back, so treating
423
- // it as undecodable (rather than coercing it) keeps a stale/mismatched trap
424
- // from ever being answered with a shape the broker would reject.
425
- function isLandstripTrap(value: unknown): value is LandstripTrap {
426
- if (!isRecord(value)) return false;
427
-
428
- switch (value.kind) {
429
- case 'filesystem':
430
- return (
431
- isLandstripOperation(value.operation) &&
432
- typeof value.path === 'string' &&
433
- typeof value.query_id === 'string'
434
- );
435
- case 'network':
436
- return (
437
- typeof value.operation === 'string' &&
438
- typeof value.target === 'string' &&
439
- typeof value.query_id === 'string'
440
- );
441
- case 'launch':
442
- return typeof value.program === 'string' && typeof value.message === 'string';
443
- case 'usage':
444
- return typeof value.message === 'string';
445
- case 'internal':
446
- return typeof value.code === 'string' && typeof value.message === 'string';
447
- default:
448
- return false;
449
- }
450
- }
451
-
452
- export function decodeLandstripTrap(value: unknown): LandstripTrap | null {
453
- return isLandstripTrap(value) ? value : null;
454
- }
455
-
456
- export function parseLandstripTraps(output: string): LandstripTrap[] {
457
- const traps: LandstripTrap[] = [];
458
-
459
- for (const line of output.split('\n')) {
460
- const trimmed = line.trim();
461
- if (trimmed.length === 0 || trimmed[0] !== '{') continue;
462
-
463
- let parsed: unknown;
464
- try {
465
- parsed = JSON.parse(trimmed);
466
- } catch {
467
- continue;
468
- }
469
-
470
- const trap = decodeLandstripTrap(parsed);
471
- if (trap) traps.push(trap);
472
- }
473
-
474
- return traps;
475
- }
476
-
477
- export function formatLandstripTrap(trap: LandstripTrap): string {
478
- switch (trap.kind) {
479
- case 'filesystem':
480
- return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
481
- trap.mechanism ? ` [${trap.mechanism}]` : ''
482
- }`;
483
- case 'network':
484
- return `landstrip: network ${trap.operation} denied (${trap.target})${
485
- trap.mechanism ? ` [${trap.mechanism}]` : ''
486
- }`;
487
- case 'launch':
488
- return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
489
- case 'usage':
490
- return `landstrip: usage error: ${trap.message}`;
491
- case 'internal': {
492
- const mechanism = trap.mechanism ? ` [${trap.mechanism}]` : '';
493
- return `landstrip: ${trap.code}${mechanism}: ${trap.message}`;
494
- }
495
- }
496
- }
497
-
498
- export function formatLandstripTraps(traps: LandstripTrap[]): string {
499
- return traps.map(formatLandstripTrap).join('\n');
500
- }
501
-
502
- // The broker matches an answer to its query by the exact decimal `query_id`
503
- // string the trap carried. A numeric id fails its deserializer, the line is
504
- // dropped, and the child's syscall stays suspended.
505
- export function controlResponseLine(
506
- queryId: string,
507
- action: LandstripControlResponse['action'],
508
- ): string {
509
- const response: LandstripControlResponse = { query_id: queryId, action };
510
- return JSON.stringify(response) + '\n';
511
- }
512
-
513
383
  export interface TrapSessionHello {
514
384
  kind: 'opencode-landstrip-session';
515
385
  sessionID: string;