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.
- package/index.ts +5 -23
- package/package.json +2 -2
- 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 {
|
|
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
|
|
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.
|
|
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.
|
|
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,
|
|
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;
|