opencode-landstrip 0.15.0 → 0.15.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/index.ts +45 -112
- package/package.json +2 -2
- package/shared.ts +187 -2
- package/tui.ts +259 -7
package/index.ts
CHANGED
|
@@ -11,14 +11,18 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
|
11
11
|
import { URL } from 'node:url';
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
type LandstripTrap,
|
|
14
15
|
type SandboxConfig,
|
|
15
16
|
type SandboxFilesystemConfig,
|
|
16
17
|
extractDomainsFromCommand,
|
|
18
|
+
formatLandstripTraps,
|
|
17
19
|
getConfigPaths,
|
|
18
20
|
isRecord,
|
|
19
21
|
landstripBinaryPath,
|
|
20
22
|
loadConfig,
|
|
21
23
|
normalizeOptions,
|
|
24
|
+
parseLandstripTraps,
|
|
25
|
+
readDiscoveryPort,
|
|
22
26
|
} from './shared.js';
|
|
23
27
|
|
|
24
28
|
interface LandstripPolicy {
|
|
@@ -32,13 +36,6 @@ interface LandstripPolicy {
|
|
|
32
36
|
filesystem: SandboxFilesystemConfig;
|
|
33
37
|
}
|
|
34
38
|
|
|
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'; detail: Record<string, string> };
|
|
41
|
-
|
|
42
39
|
interface BashSandboxState {
|
|
43
40
|
originalCommand: string;
|
|
44
41
|
wrappedCommand: string;
|
|
@@ -58,15 +55,10 @@ interface SandboxPermissionDecision {
|
|
|
58
55
|
|
|
59
56
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
60
57
|
|
|
61
|
-
const LANDSTRIP_VERSION = [0, 15,
|
|
58
|
+
const LANDSTRIP_VERSION = [0, 15, 8] as const;
|
|
62
59
|
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
63
|
-
const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
|
|
64
60
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
65
61
|
|
|
66
|
-
function isLandstripOperation(value: unknown): value is 'read' | 'write' {
|
|
67
|
-
return typeof value === 'string' && LANDSTRIP_OPERATIONS.has(value as 'read' | 'write');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
62
|
function expandPath(filePath: string, baseDirectory: string): string {
|
|
71
63
|
const expanded = filePath.replace(/^~(?=$|[/])/, homedir());
|
|
72
64
|
return resolve(isAbsolute(expanded) ? expanded : join(baseDirectory, expanded));
|
|
@@ -245,8 +237,8 @@ function extractBlockedPath(
|
|
|
245
237
|
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
246
238
|
|
|
247
239
|
// Landstrip structured trap format carrying a denied path
|
|
248
|
-
const
|
|
249
|
-
for (const trap of
|
|
240
|
+
const landstripTraps = parseLandstripTraps(output);
|
|
241
|
+
for (const trap of landstripTraps) {
|
|
250
242
|
if (trap.kind === 'filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
|
|
251
243
|
if (trap.kind === 'internal' && trap.detail.file) {
|
|
252
244
|
return normalizeBlockedPath(trap.detail.file, baseDirectory);
|
|
@@ -255,7 +247,7 @@ function extractBlockedPath(
|
|
|
255
247
|
|
|
256
248
|
// If landstrip reported a trap but without a path, try to
|
|
257
249
|
// extract the blocked path from the command itself
|
|
258
|
-
if (
|
|
250
|
+
if (landstripTraps.length > 0 && command) {
|
|
259
251
|
for (const candidate of extractCandidatePaths(command)) {
|
|
260
252
|
const resolved = canonicalizePath(candidate, baseDirectory);
|
|
261
253
|
return resolved;
|
|
@@ -405,95 +397,6 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
405
397
|
return true;
|
|
406
398
|
}
|
|
407
399
|
|
|
408
|
-
function decodeLandstripTrap(value: unknown): LandstripTrap | null {
|
|
409
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
|
410
|
-
const record = value as Record<string, unknown>;
|
|
411
|
-
const mechanism = typeof record.mechanism === 'string' ? record.mechanism : '';
|
|
412
|
-
|
|
413
|
-
switch (record.kind) {
|
|
414
|
-
case 'filesystem': {
|
|
415
|
-
const { operation, path } = record;
|
|
416
|
-
if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
|
|
417
|
-
return { kind: 'filesystem', operation, path, mechanism };
|
|
418
|
-
}
|
|
419
|
-
case 'network': {
|
|
420
|
-
const { operation, target } = record;
|
|
421
|
-
if (typeof operation !== 'string' || typeof target !== 'string') return null;
|
|
422
|
-
return { kind: 'network', operation, target, mechanism };
|
|
423
|
-
}
|
|
424
|
-
case 'launch': {
|
|
425
|
-
const { program, message } = record;
|
|
426
|
-
if (typeof program !== 'string') return null;
|
|
427
|
-
return { kind: 'launch', program, message: typeof message === 'string' ? message : '' };
|
|
428
|
-
}
|
|
429
|
-
case 'usage': {
|
|
430
|
-
const { message } = record;
|
|
431
|
-
if (typeof message !== 'string') return null;
|
|
432
|
-
return { kind: 'usage', message };
|
|
433
|
-
}
|
|
434
|
-
case 'internal': {
|
|
435
|
-
const detail: Record<string, string> = {};
|
|
436
|
-
const payload = record.detail;
|
|
437
|
-
if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) {
|
|
438
|
-
for (const [key, val] of Object.entries(payload as Record<string, unknown>)) {
|
|
439
|
-
detail[key] = typeof val === 'string' ? val : JSON.stringify(val);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
return { kind: 'internal', detail };
|
|
443
|
-
}
|
|
444
|
-
default:
|
|
445
|
-
return null;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function parseLandstripErrors(output: string): LandstripTrap[] {
|
|
450
|
-
const traps: LandstripTrap[] = [];
|
|
451
|
-
|
|
452
|
-
for (const line of output.split('\n')) {
|
|
453
|
-
const trimmed = line.trim();
|
|
454
|
-
if (trimmed.length === 0 || trimmed[0] !== '{') continue;
|
|
455
|
-
|
|
456
|
-
let parsed: unknown;
|
|
457
|
-
try {
|
|
458
|
-
parsed = JSON.parse(trimmed);
|
|
459
|
-
} catch {
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const trap = decodeLandstripTrap(parsed);
|
|
464
|
-
if (trap) traps.push(trap);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return traps;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function formatLandstripTrap(trap: LandstripTrap): string {
|
|
471
|
-
switch (trap.kind) {
|
|
472
|
-
case 'filesystem':
|
|
473
|
-
return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
|
|
474
|
-
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
475
|
-
}`;
|
|
476
|
-
case 'network':
|
|
477
|
-
return `landstrip: network ${trap.operation} denied (${trap.target})${
|
|
478
|
-
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
479
|
-
}`;
|
|
480
|
-
case 'launch':
|
|
481
|
-
return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
|
|
482
|
-
case 'usage':
|
|
483
|
-
return `landstrip: usage error: ${trap.message}`;
|
|
484
|
-
case 'internal': {
|
|
485
|
-
const detail = Object.entries(trap.detail)
|
|
486
|
-
.map(([key, val]) => `${key}: ${val}`)
|
|
487
|
-
.join(', ');
|
|
488
|
-
return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function formatLandstripErrors(traps: LandstripTrap[]): string {
|
|
494
|
-
return traps.map(formatLandstripTrap).join('\n');
|
|
495
|
-
}
|
|
496
|
-
|
|
497
400
|
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
498
401
|
const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
499
402
|
if (bracketMatch) {
|
|
@@ -727,15 +630,37 @@ function shellArgs(shell: string, command: string): string[] {
|
|
|
727
630
|
return [shell, '-lc', command];
|
|
728
631
|
}
|
|
729
632
|
|
|
730
|
-
|
|
731
|
-
|
|
633
|
+
// The query-response port is published by the TUI plugin and is Linux-only (the
|
|
634
|
+
// socket protocol exists only in landstrip's seccomp broker).
|
|
635
|
+
function socketQueryPort(baseDirectory: string): number | null {
|
|
636
|
+
if (process.platform !== 'linux') return null;
|
|
637
|
+
return readDiscoveryPort(baseDirectory);
|
|
638
|
+
}
|
|
732
639
|
|
|
733
|
-
|
|
640
|
+
function buildWrappedCommand(
|
|
641
|
+
policyPath: string,
|
|
642
|
+
shell: string,
|
|
643
|
+
command: string,
|
|
644
|
+
trapPort: number | null,
|
|
645
|
+
): string {
|
|
646
|
+
const baseArgs = ['-p', policyPath, ...shellArgs(shell, command)];
|
|
647
|
+
const plain = [landstripBinaryPath(), ...baseArgs].map(shellQuote).join(' ');
|
|
648
|
+
if (trapPort === null) return plain;
|
|
649
|
+
|
|
650
|
+
// Connect fd 3 to the TUI's query-response socket BEFORE landstrip applies the
|
|
651
|
+
// sandbox, so a denied write can be approved live instead of forcing a re-run.
|
|
652
|
+
// The `&&`/`||` guard falls back to the plain (no --trap-fd) invocation when the
|
|
653
|
+
// redirect fails — a dead port, a non-bash outer shell without /dev/tcp, or an
|
|
654
|
+
// outer `set -e` — so a failed connect never hands the broker a dead fd 3.
|
|
655
|
+
const trapped = [landstripBinaryPath(), '--trap-fd', '3', ...baseArgs].map(shellQuote).join(' ');
|
|
656
|
+
return `{ exec 3<>/dev/tcp/127.0.0.1/${trapPort} ; } 2>/dev/null && ${trapped} || ${plain}`;
|
|
734
657
|
}
|
|
735
658
|
|
|
736
659
|
function isGeneratedWrappedCommand(command: string): boolean {
|
|
737
660
|
return (
|
|
738
|
-
|
|
661
|
+
// `.includes` rather than `.startsWith`: the query-response form prefixes a
|
|
662
|
+
// `{ exec 3<>/dev/tcp/...; } && ` redirect before the landstrip invocation.
|
|
663
|
+
command.includes(`${shellQuote(landstripBinaryPath())} `) &&
|
|
739
664
|
command.includes(` ${shellQuote('-p')} `) &&
|
|
740
665
|
command.includes('opencode-landstrip-')
|
|
741
666
|
);
|
|
@@ -779,7 +704,10 @@ function extractOriginalCommand(wrappedCommand: string): string | null {
|
|
|
779
704
|
const flagIdx = pIdx + 3;
|
|
780
705
|
const flag = args[flagIdx];
|
|
781
706
|
if (flag !== '-lc' && flag !== '-c') return null;
|
|
782
|
-
|
|
707
|
+
// The query-response form appends `|| <plain invocation>`; stop at that
|
|
708
|
+
// separator so the fallback branch is not folded into the recovered command.
|
|
709
|
+
const end = args.indexOf('||', flagIdx + 1);
|
|
710
|
+
return (end === -1 ? args.slice(flagIdx + 1) : args.slice(flagIdx + 1, end)).join(' ');
|
|
783
711
|
}
|
|
784
712
|
|
|
785
713
|
function getToolPath(args: Record<string, unknown>): string | undefined {
|
|
@@ -1151,6 +1079,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1151
1079
|
policy.path,
|
|
1152
1080
|
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
1153
1081
|
originalCommand,
|
|
1082
|
+
socketQueryPort(directory),
|
|
1154
1083
|
);
|
|
1155
1084
|
|
|
1156
1085
|
activeBash.set(callID, {
|
|
@@ -1316,9 +1245,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1316
1245
|
}
|
|
1317
1246
|
|
|
1318
1247
|
const outputText = output?.output ?? '';
|
|
1319
|
-
|
|
1248
|
+
// Query traps were already resolved interactively over the socket by the
|
|
1249
|
+
// TUI plugin; only terminal (info) traps belong in the after-the-fact toast.
|
|
1250
|
+
const errors = parseLandstripTraps(outputText).filter(
|
|
1251
|
+
(trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
|
|
1252
|
+
);
|
|
1320
1253
|
if (errors.length > 0) {
|
|
1321
|
-
const message =
|
|
1254
|
+
const message = formatLandstripTraps(errors);
|
|
1322
1255
|
await client.tui
|
|
1323
1256
|
?.showToast?.({
|
|
1324
1257
|
body: { title: 'opencode-landstrip', message, variant: 'error' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ci:test": "npm test"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@landstrip/landstrip": "^0.15.
|
|
52
|
+
"@landstrip/landstrip": "^0.15.8"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@opencode-ai/plugin": "^1.17.7",
|
package/shared.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { binaryPath } from '@landstrip/landstrip';
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir, tmpdir } from 'node:os';
|
|
8
9
|
import { dirname, join } from 'node:path';
|
|
9
10
|
|
|
10
11
|
export interface SandboxFilesystemConfig {
|
|
@@ -316,3 +317,187 @@ export function updateForPermission(
|
|
|
316
317
|
|
|
317
318
|
return null;
|
|
318
319
|
}
|
|
320
|
+
|
|
321
|
+
// Landstrip emits one JSON trap per line on its trap fd/file. The `state` field
|
|
322
|
+
// (landstrip >= 0.15.4) distinguishes a terminal `info` trap from a `query`
|
|
323
|
+
// trap that holds a syscall pending until the host answers with `queryId`. It
|
|
324
|
+
// is absent on the static-profile platforms (macOS/Windows), so both fields are
|
|
325
|
+
// optional. Parsing lives here so the server hook and the TUI socket handler
|
|
326
|
+
// decode identically.
|
|
327
|
+
export type LandstripTrapState = 'query' | 'info';
|
|
328
|
+
|
|
329
|
+
export type LandstripTrap =
|
|
330
|
+
| {
|
|
331
|
+
kind: 'filesystem';
|
|
332
|
+
operation: 'read' | 'write';
|
|
333
|
+
path: string;
|
|
334
|
+
mechanism: string;
|
|
335
|
+
state?: LandstripTrapState;
|
|
336
|
+
queryId?: number;
|
|
337
|
+
}
|
|
338
|
+
| { kind: 'network'; operation: string; target: string; mechanism: string }
|
|
339
|
+
| { kind: 'launch'; program: string; message: string }
|
|
340
|
+
| { kind: 'usage'; message: string }
|
|
341
|
+
| { kind: 'internal'; detail: Record<string, string> };
|
|
342
|
+
|
|
343
|
+
const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
|
|
344
|
+
|
|
345
|
+
function isLandstripOperation(value: unknown): value is 'read' | 'write' {
|
|
346
|
+
return typeof value === 'string' && LANDSTRIP_OPERATIONS.has(value as 'read' | 'write');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function decodeTrapState(value: unknown): LandstripTrapState | undefined {
|
|
350
|
+
return value === 'query' || value === 'info' ? value : undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function decodeLandstripTrap(value: unknown): LandstripTrap | null {
|
|
354
|
+
if (!isRecord(value)) return null;
|
|
355
|
+
const mechanism = typeof value.mechanism === 'string' ? value.mechanism : '';
|
|
356
|
+
|
|
357
|
+
switch (value.kind) {
|
|
358
|
+
case 'filesystem': {
|
|
359
|
+
const { operation, path } = value;
|
|
360
|
+
if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
|
|
361
|
+
const state = decodeTrapState(value.state);
|
|
362
|
+
const queryId = typeof value.query_id === 'number' ? value.query_id : undefined;
|
|
363
|
+
return { kind: 'filesystem', operation, path, mechanism, state, queryId };
|
|
364
|
+
}
|
|
365
|
+
case 'network': {
|
|
366
|
+
const { operation, target } = value;
|
|
367
|
+
if (typeof operation !== 'string' || typeof target !== 'string') return null;
|
|
368
|
+
return { kind: 'network', operation, target, mechanism };
|
|
369
|
+
}
|
|
370
|
+
case 'launch': {
|
|
371
|
+
const { program, message } = value;
|
|
372
|
+
if (typeof program !== 'string') return null;
|
|
373
|
+
return { kind: 'launch', program, message: typeof message === 'string' ? message : '' };
|
|
374
|
+
}
|
|
375
|
+
case 'usage': {
|
|
376
|
+
const { message } = value;
|
|
377
|
+
if (typeof message !== 'string') return null;
|
|
378
|
+
return { kind: 'usage', message };
|
|
379
|
+
}
|
|
380
|
+
case 'internal': {
|
|
381
|
+
const detail: Record<string, string> = {};
|
|
382
|
+
const payload = value.detail;
|
|
383
|
+
if (isRecord(payload)) {
|
|
384
|
+
for (const [key, val] of Object.entries(payload)) {
|
|
385
|
+
detail[key] = typeof val === 'string' ? val : JSON.stringify(val);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return { kind: 'internal', detail };
|
|
389
|
+
}
|
|
390
|
+
default:
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function parseLandstripTraps(output: string): LandstripTrap[] {
|
|
396
|
+
const traps: LandstripTrap[] = [];
|
|
397
|
+
|
|
398
|
+
for (const line of output.split('\n')) {
|
|
399
|
+
const trimmed = line.trim();
|
|
400
|
+
if (trimmed.length === 0 || trimmed[0] !== '{') continue;
|
|
401
|
+
|
|
402
|
+
let parsed: unknown;
|
|
403
|
+
try {
|
|
404
|
+
parsed = JSON.parse(trimmed);
|
|
405
|
+
} catch {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const trap = decodeLandstripTrap(parsed);
|
|
410
|
+
if (trap) traps.push(trap);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return traps;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function formatLandstripTrap(trap: LandstripTrap): string {
|
|
417
|
+
switch (trap.kind) {
|
|
418
|
+
case 'filesystem':
|
|
419
|
+
return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
|
|
420
|
+
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
421
|
+
}`;
|
|
422
|
+
case 'network':
|
|
423
|
+
return `landstrip: network ${trap.operation} denied (${trap.target})${
|
|
424
|
+
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
425
|
+
}`;
|
|
426
|
+
case 'launch':
|
|
427
|
+
return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
|
|
428
|
+
case 'usage':
|
|
429
|
+
return `landstrip: usage error: ${trap.message}`;
|
|
430
|
+
case 'internal': {
|
|
431
|
+
const detail = Object.entries(trap.detail)
|
|
432
|
+
.map(([key, val]) => `${key}: ${val}`)
|
|
433
|
+
.join(', ');
|
|
434
|
+
return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function formatLandstripTraps(traps: LandstripTrap[]): string {
|
|
440
|
+
return traps.map(formatLandstripTrap).join('\n');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The TUI plugin runs the query-response socket server and publishes its port
|
|
444
|
+
// to a per-directory discovery file; the server plugin reads it to inject the
|
|
445
|
+
// fd-3 redirect. Namespacing by a hash of the realpath keeps concurrent
|
|
446
|
+
// opencode instances in different projects from colliding.
|
|
447
|
+
function discoveryDir(): string {
|
|
448
|
+
const base = process.env.XDG_RUNTIME_DIR || tmpdir();
|
|
449
|
+
return join(base, 'opencode-landstrip');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function discoveryFilePath(baseDirectory: string): string {
|
|
453
|
+
let key = baseDirectory;
|
|
454
|
+
try {
|
|
455
|
+
key = realpathSync.native(baseDirectory);
|
|
456
|
+
} catch {
|
|
457
|
+
// Directory not resolvable — hash the raw path instead.
|
|
458
|
+
}
|
|
459
|
+
const hash = createHash('sha256').update(key).digest('hex').slice(0, 16);
|
|
460
|
+
return join(discoveryDir(), `port-${hash}.json`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function writeDiscoveryPort(baseDirectory: string, port: number): void {
|
|
464
|
+
const dir = discoveryDir();
|
|
465
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
466
|
+
writeFileSync(
|
|
467
|
+
discoveryFilePath(baseDirectory),
|
|
468
|
+
JSON.stringify({ port, pid: process.pid, ts: Date.now() }) + '\n',
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function removeDiscoveryFile(baseDirectory: string): void {
|
|
473
|
+
rmSync(discoveryFilePath(baseDirectory), { force: true });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Returns the live query-response port, or null when no fresh server is
|
|
477
|
+
// listening. A recorded writer pid that no longer exists marks the file stale.
|
|
478
|
+
export function readDiscoveryPort(baseDirectory: string): number | null {
|
|
479
|
+
const path = discoveryFilePath(baseDirectory);
|
|
480
|
+
if (!existsSync(path)) return null;
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const data: unknown = JSON.parse(readFileSync(path, 'utf-8'));
|
|
484
|
+
if (!isRecord(data)) return null;
|
|
485
|
+
|
|
486
|
+
const port = typeof data.port === 'number' ? data.port : NaN;
|
|
487
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
488
|
+
|
|
489
|
+
if (typeof data.pid === 'number') {
|
|
490
|
+
try {
|
|
491
|
+
process.kill(data.pid, 0);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
// ESRCH: the writer is gone, so the file is stale. EPERM: alive but
|
|
494
|
+
// owned by another user — still a live listener, so accept it.
|
|
495
|
+
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return port;
|
|
500
|
+
} catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
}
|
package/tui.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
|
|
5
5
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
+
import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
type SandboxConfigOverrides,
|
|
@@ -11,10 +12,13 @@ import {
|
|
|
11
12
|
landstripBinaryPath,
|
|
12
13
|
loadConfig,
|
|
13
14
|
normalizeOptions,
|
|
15
|
+
parseLandstripTraps,
|
|
14
16
|
permissionLabel,
|
|
15
17
|
permissionResource,
|
|
18
|
+
removeDiscoveryFile,
|
|
16
19
|
updateForPermission,
|
|
17
20
|
writeConfigFile,
|
|
21
|
+
writeDiscoveryPort,
|
|
18
22
|
} from './shared.js';
|
|
19
23
|
|
|
20
24
|
// The shape shared by the `permission.asked` event payload and the entries
|
|
@@ -31,6 +35,27 @@ interface PendingPermission {
|
|
|
31
35
|
|
|
32
36
|
type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
|
|
33
37
|
|
|
38
|
+
type WriteChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
|
|
39
|
+
|
|
40
|
+
// A landstrip write query held pending over the fd-3 socket. It shares the
|
|
41
|
+
// dialog stack with permission prompts so the two never overlap, hence the
|
|
42
|
+
// common `id`/`kind` shape.
|
|
43
|
+
interface WriteQueryEntry {
|
|
44
|
+
kind: 'write-query';
|
|
45
|
+
id: string;
|
|
46
|
+
socket: NetSocket;
|
|
47
|
+
queryId: number;
|
|
48
|
+
path: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PermissionEntry {
|
|
52
|
+
kind: 'permission';
|
|
53
|
+
id: string;
|
|
54
|
+
permission: PendingPermission;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type QueueEntry = PermissionEntry | WriteQueryEntry;
|
|
58
|
+
|
|
34
59
|
function list(values: string[]): string {
|
|
35
60
|
return values.join(', ') || '(none)';
|
|
36
61
|
}
|
|
@@ -84,26 +109,44 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
84
109
|
// Permission requests can arrive twice (the live event and a reconnect replay
|
|
85
110
|
// of `api.state`), so `resolved` tracks ids we have already answered and
|
|
86
111
|
// `activeId` guards against stacking a second sandbox dialog on the first.
|
|
112
|
+
// Write queries share the same queue so a held-write prompt never stacks on a
|
|
113
|
+
// permission prompt.
|
|
87
114
|
const resolved = new Set<string>();
|
|
88
|
-
const queue:
|
|
115
|
+
const queue: QueueEntry[] = [];
|
|
89
116
|
let activeId: string | undefined;
|
|
90
117
|
|
|
118
|
+
// Paths the user approved "for session": later queries for the same path are
|
|
119
|
+
// auto-allowed without a dialog. This lives only in the TUI process — the
|
|
120
|
+
// server regenerates the policy from on-disk config each run — so it affects
|
|
121
|
+
// only live socket decisions, not the static policy.
|
|
122
|
+
const sessionAllowedWritePaths = new Set<string>();
|
|
123
|
+
|
|
124
|
+
// Write queries still awaiting a response, so cleanup can release held
|
|
125
|
+
// syscalls instead of letting the child hang.
|
|
126
|
+
const liveQueries = new Set<WriteQueryEntry>();
|
|
127
|
+
|
|
91
128
|
function pump(): void {
|
|
92
129
|
if (activeId !== undefined) return;
|
|
93
130
|
let next = queue.shift();
|
|
94
131
|
while (next && resolved.has(next.id)) next = queue.shift();
|
|
95
132
|
if (!next) return;
|
|
96
|
-
showPermission(next);
|
|
133
|
+
if (next.kind === 'permission') showPermission(next.permission);
|
|
134
|
+
else showWriteQuery(next);
|
|
97
135
|
}
|
|
98
136
|
|
|
99
|
-
function
|
|
100
|
-
if (!
|
|
101
|
-
if (activeId ===
|
|
102
|
-
if (queue.some((item) => item.id ===
|
|
103
|
-
queue.push(
|
|
137
|
+
function enqueueEntry(entry: QueueEntry): void {
|
|
138
|
+
if (!entry.id || resolved.has(entry.id)) return;
|
|
139
|
+
if (activeId === entry.id) return;
|
|
140
|
+
if (queue.some((item) => item.id === entry.id)) return;
|
|
141
|
+
queue.push(entry);
|
|
104
142
|
pump();
|
|
105
143
|
}
|
|
106
144
|
|
|
145
|
+
function enqueue(permission: PendingPermission): void {
|
|
146
|
+
if (!permission.id) return;
|
|
147
|
+
enqueueEntry({ kind: 'permission', id: permission.id, permission });
|
|
148
|
+
}
|
|
149
|
+
|
|
107
150
|
// Safety net for missed/late events and reconnects: fold whatever the host
|
|
108
151
|
// still considers pending for this session back into the queue.
|
|
109
152
|
function reconcile(sessionID: string): void {
|
|
@@ -224,6 +267,107 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
224
267
|
);
|
|
225
268
|
}
|
|
226
269
|
|
|
270
|
+
function respondWriteQuery(socket: NetSocket, queryId: number, action: 'allow' | 'deny'): void {
|
|
271
|
+
if (!socket.destroyed) socket.write(JSON.stringify({ query_id: queryId, action }) + '\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function resolveWriteQuery(entry: WriteQueryEntry, choice: WriteChoice): void {
|
|
275
|
+
if (resolved.has(entry.id)) return;
|
|
276
|
+
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
if (action === 'allow') {
|
|
280
|
+
if (choice === 'session') {
|
|
281
|
+
sessionAllowedWritePaths.add(entry.path);
|
|
282
|
+
} else if (choice === 'project' || choice === 'global') {
|
|
283
|
+
const directory = api.state.path.directory || process.cwd();
|
|
284
|
+
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
285
|
+
const update = updateForPermission({
|
|
286
|
+
permission: 'write',
|
|
287
|
+
metadata: { filepath: entry.path },
|
|
288
|
+
});
|
|
289
|
+
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
respondWriteQuery(entry.socket, entry.queryId, action);
|
|
294
|
+
api.ui.toast({
|
|
295
|
+
title: 'Sandbox',
|
|
296
|
+
message: action === 'deny' ? `Write denied: ${entry.path}` : `Write allowed (${choice})`,
|
|
297
|
+
variant: action === 'deny' ? 'warning' : 'success',
|
|
298
|
+
});
|
|
299
|
+
} catch {
|
|
300
|
+
// Persisting failed — still release the held syscall by denying it.
|
|
301
|
+
respondWriteQuery(entry.socket, entry.queryId, 'deny');
|
|
302
|
+
} finally {
|
|
303
|
+
liveQueries.delete(entry);
|
|
304
|
+
finishActive(entry.id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function showWriteQuery(entry: WriteQueryEntry): void {
|
|
309
|
+
activeId = entry.id;
|
|
310
|
+
|
|
311
|
+
void api.attention.notify({
|
|
312
|
+
title: 'Sandbox write blocked',
|
|
313
|
+
message: entry.path,
|
|
314
|
+
sound: { name: 'permission' },
|
|
315
|
+
notification: true,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// A selection pops the dialog (firing `onClose`); track it so the esc-path
|
|
319
|
+
// deny does not override the user's choice. A held syscall must always be
|
|
320
|
+
// answered, so esc/dismiss denies rather than leaving it unresolved.
|
|
321
|
+
let selectionMade = false;
|
|
322
|
+
|
|
323
|
+
api.ui.dialog.replace(
|
|
324
|
+
() =>
|
|
325
|
+
api.ui.DialogSelect<WriteChoice>({
|
|
326
|
+
title: 'Sandbox Write Blocked',
|
|
327
|
+
placeholder: `Write blocked: ${entry.path}`,
|
|
328
|
+
options: [
|
|
329
|
+
{
|
|
330
|
+
title: 'Allow once',
|
|
331
|
+
value: 'once',
|
|
332
|
+
category: 'This write',
|
|
333
|
+
description: 'Permit this write and continue',
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
title: 'Allow for session',
|
|
337
|
+
value: 'session',
|
|
338
|
+
category: 'This write',
|
|
339
|
+
description: 'Permit writes to this path for the rest of this session',
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
title: 'Allow for project',
|
|
343
|
+
value: 'project',
|
|
344
|
+
category: 'Persist to sandbox.json',
|
|
345
|
+
description: 'Add to .opencode/sandbox.json allowWrite and permit',
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
title: 'Allow globally',
|
|
349
|
+
value: 'global',
|
|
350
|
+
category: 'Persist to sandbox.json',
|
|
351
|
+
description: 'Add to ~/.config/opencode/sandbox.json allowWrite and permit',
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
title: 'Deny',
|
|
355
|
+
value: 'deny',
|
|
356
|
+
category: 'Deny',
|
|
357
|
+
description: 'Block this write',
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
onSelect: (option) => {
|
|
361
|
+
selectionMade = true;
|
|
362
|
+
resolveWriteQuery(entry, option.value);
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
() => {
|
|
366
|
+
if (!selectionMade) resolveWriteQuery(entry, 'deny');
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
227
371
|
const unsubscribeAsked = api.event.on('permission.asked', (event) => {
|
|
228
372
|
const pending = event.properties as PendingPermission;
|
|
229
373
|
enqueue(pending);
|
|
@@ -234,6 +378,98 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
234
378
|
finishActive(event.properties.requestID);
|
|
235
379
|
});
|
|
236
380
|
|
|
381
|
+
// Query-response socket server (Linux-only — landstrip's socket protocol lives
|
|
382
|
+
// in the seccomp broker). The server plugin connects each sandboxed run's
|
|
383
|
+
// fd 3 here via a /dev/tcp redirect and we answer held writes interactively.
|
|
384
|
+
const sockets = new Set<NetSocket>();
|
|
385
|
+
let socketServer: ReturnType<typeof createServer> | undefined;
|
|
386
|
+
|
|
387
|
+
if (process.platform === 'linux') {
|
|
388
|
+
const baseDirectory = api.state.path.directory || process.cwd();
|
|
389
|
+
let socketSeq = 0;
|
|
390
|
+
|
|
391
|
+
socketServer = createServer((socket) => {
|
|
392
|
+
sockets.add(socket);
|
|
393
|
+
socket.setEncoding('utf8');
|
|
394
|
+
const socketId = ++socketSeq;
|
|
395
|
+
const seen = new Set<number>();
|
|
396
|
+
let buffer = '';
|
|
397
|
+
|
|
398
|
+
socket.on('data', (chunk: string | Buffer) => {
|
|
399
|
+
buffer += chunk.toString();
|
|
400
|
+
if (buffer.length > 1024 * 1024) {
|
|
401
|
+
socket.destroy();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let newline: number;
|
|
406
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
407
|
+
const line = buffer.slice(0, newline);
|
|
408
|
+
buffer = buffer.slice(newline + 1);
|
|
409
|
+
|
|
410
|
+
for (const trap of parseLandstripTraps(line)) {
|
|
411
|
+
if (
|
|
412
|
+
trap.kind !== 'filesystem' ||
|
|
413
|
+
trap.state !== 'query' ||
|
|
414
|
+
trap.operation !== 'write'
|
|
415
|
+
) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (typeof trap.queryId !== 'number' || seen.has(trap.queryId)) continue;
|
|
419
|
+
seen.add(trap.queryId);
|
|
420
|
+
|
|
421
|
+
if (sessionAllowedWritePaths.has(trap.path)) {
|
|
422
|
+
respondWriteQuery(socket, trap.queryId, 'allow');
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const entry: WriteQueryEntry = {
|
|
427
|
+
kind: 'write-query',
|
|
428
|
+
id: `landstrip-write:${socketId}:${trap.queryId}`,
|
|
429
|
+
socket,
|
|
430
|
+
queryId: trap.queryId,
|
|
431
|
+
path: trap.path,
|
|
432
|
+
};
|
|
433
|
+
liveQueries.add(entry);
|
|
434
|
+
enqueueEntry(entry);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const cleanup = () => {
|
|
440
|
+
sockets.delete(socket);
|
|
441
|
+
// The child is gone; drop our holds for this socket so the queue moves on.
|
|
442
|
+
// Deleting the current entry mid-iteration is well-defined for a Set.
|
|
443
|
+
for (const entry of liveQueries) {
|
|
444
|
+
if (entry.socket !== socket) continue;
|
|
445
|
+
liveQueries.delete(entry);
|
|
446
|
+
finishActive(entry.id);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
socket.on('error', cleanup);
|
|
450
|
+
socket.on('close', cleanup);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
socketServer.on('error', () => {
|
|
454
|
+
try {
|
|
455
|
+
removeDiscoveryFile(baseDirectory);
|
|
456
|
+
} catch {
|
|
457
|
+
// best effort
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
socketServer.listen(0, '127.0.0.1', () => {
|
|
462
|
+
const address = socketServer?.address() as AddressInfo | null;
|
|
463
|
+
if (address && typeof address === 'object') {
|
|
464
|
+
try {
|
|
465
|
+
writeDiscoveryPort(baseDirectory, address.port);
|
|
466
|
+
} catch {
|
|
467
|
+
// best effort — falls back to the server's reset model
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
237
473
|
const showSandbox = () => {
|
|
238
474
|
const directory = api.state.path.directory || process.cwd();
|
|
239
475
|
const message = sandboxSummary(directory, optionOverrides);
|
|
@@ -335,6 +571,22 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
335
571
|
api.lifecycle.onDispose(() => {
|
|
336
572
|
unsubscribeAsked();
|
|
337
573
|
unsubscribeReplied();
|
|
574
|
+
|
|
575
|
+
// Deny any still-held writes so the sandboxed children don't hang, then
|
|
576
|
+
// tear down the socket server and drop the discovery file.
|
|
577
|
+
for (const entry of liveQueries) {
|
|
578
|
+
respondWriteQuery(entry.socket, entry.queryId, 'deny');
|
|
579
|
+
liveQueries.delete(entry);
|
|
580
|
+
}
|
|
581
|
+
for (const socket of sockets) socket.destroy();
|
|
582
|
+
if (socketServer) {
|
|
583
|
+
socketServer.close();
|
|
584
|
+
try {
|
|
585
|
+
removeDiscoveryFile(api.state.path.directory || process.cwd());
|
|
586
|
+
} catch {
|
|
587
|
+
// best effort
|
|
588
|
+
}
|
|
589
|
+
}
|
|
338
590
|
});
|
|
339
591
|
};
|
|
340
592
|
|