opencode-landstrip 0.16.14 → 0.16.16
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 +140 -25
- package/package.json +1 -1
- package/shared.ts +46 -0
- package/tui.ts +2 -51
package/index.ts
CHANGED
|
@@ -24,8 +24,8 @@ import {
|
|
|
24
24
|
parseLandstripTraps,
|
|
25
25
|
permissionPatterns,
|
|
26
26
|
permissionType,
|
|
27
|
-
readDiscoveryPort,
|
|
28
27
|
sandboxSummary,
|
|
28
|
+
sessionScopeFor,
|
|
29
29
|
} from './shared.js';
|
|
30
30
|
|
|
31
31
|
interface LandstripPolicy {
|
|
@@ -45,6 +45,9 @@ interface BashSandboxState {
|
|
|
45
45
|
policyDir: string;
|
|
46
46
|
port: number | null;
|
|
47
47
|
stop: (() => Promise<void>) | null;
|
|
48
|
+
trapServer: ReturnType<typeof createServer> | null;
|
|
49
|
+
trapServerPort: number | null;
|
|
50
|
+
trapLines: string[];
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
type SandboxPermissionKind = 'read' | 'write' | 'domain';
|
|
@@ -632,20 +635,83 @@ function shellArgs(shell: string, command: string): string[] {
|
|
|
632
635
|
return [shell, '-lc', command];
|
|
633
636
|
}
|
|
634
637
|
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
638
|
+
// Start a local TCP server that landstrip connects its trap fd to. Traps are
|
|
639
|
+
// handled in-process: query traps are answered immediately against the active
|
|
640
|
+
// config, and info traps are collected for post-execution error reporting.
|
|
641
|
+
function startTrapServer(
|
|
642
|
+
effectiveAllowRead: string[],
|
|
643
|
+
effectiveAllowWrite: string[],
|
|
644
|
+
denyRead: string[],
|
|
645
|
+
denyWrite: string[],
|
|
646
|
+
baseDirectory: string,
|
|
647
|
+
sessionAllowedReadPaths: Set<string>,
|
|
648
|
+
sessionAllowedWritePaths: Set<string>,
|
|
649
|
+
): Promise<{ server: ReturnType<typeof createServer>; port: number; trapLines: string[] }> {
|
|
650
|
+
const trapLines: string[] = [];
|
|
651
|
+
const server = createServer((trapSocket) => {
|
|
652
|
+
let buffer = '';
|
|
653
|
+
trapSocket.on('data', (data: Buffer) => {
|
|
654
|
+
buffer += data.toString('utf8');
|
|
655
|
+
let nl = buffer.indexOf('\n');
|
|
656
|
+
while (nl !== -1) {
|
|
657
|
+
const line = buffer.slice(0, nl);
|
|
658
|
+
buffer = buffer.slice(nl + 1);
|
|
659
|
+
nl = buffer.indexOf('\n');
|
|
660
|
+
if (line.length === 0) continue;
|
|
661
|
+
let obj: Record<string, unknown> | null = null;
|
|
662
|
+
try {
|
|
663
|
+
const parsed: unknown = JSON.parse(line);
|
|
664
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
665
|
+
obj = parsed as Record<string, unknown>;
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
obj = null;
|
|
669
|
+
}
|
|
670
|
+
if (
|
|
671
|
+
obj &&
|
|
672
|
+
obj.state === 'query' &&
|
|
673
|
+
typeof obj.query_id === 'number' &&
|
|
674
|
+
(obj.operation === 'read' || obj.operation === 'write') &&
|
|
675
|
+
typeof obj.path === 'string'
|
|
676
|
+
) {
|
|
677
|
+
const path = canonicalizePath(obj.path, baseDirectory);
|
|
678
|
+
const operation = obj.operation as 'read' | 'write';
|
|
679
|
+
// Per landstrip policy: a deny rule overrides allow only when more
|
|
680
|
+
// specific; a tie or more-specific allow carves the path back in.
|
|
681
|
+
const denyReadDepth = matchDepth(path, denyRead, baseDirectory);
|
|
682
|
+
const allowReadDepth = matchDepth(path, effectiveAllowRead, baseDirectory);
|
|
683
|
+
const denyWriteDepth = matchDepth(path, denyWrite, baseDirectory);
|
|
684
|
+
const allowWriteDepth = matchDepth(path, effectiveAllowWrite, baseDirectory);
|
|
685
|
+
const allowed =
|
|
686
|
+
operation === 'read'
|
|
687
|
+
? !(denyReadDepth > allowReadDepth) && allowReadDepth >= 0
|
|
688
|
+
: !(denyWriteDepth > allowWriteDepth) && allowWriteDepth >= 0;
|
|
689
|
+
if (allowed) {
|
|
690
|
+
trapSocket.write(JSON.stringify({ query_id: obj.query_id, action: 'allow' }) + '\n');
|
|
691
|
+
} else {
|
|
692
|
+
// Auto-grant via session scope and allow so the command proceeds.
|
|
693
|
+
const scope = sessionScopeFor(path, baseDirectory);
|
|
694
|
+
if (operation === 'read') sessionAllowedReadPaths.add(scope);
|
|
695
|
+
else sessionAllowedWritePaths.add(scope);
|
|
696
|
+
trapSocket.write(JSON.stringify({ query_id: obj.query_id, action: 'allow' }) + '\n');
|
|
697
|
+
trapLines.push(line);
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
trapLines.push(line);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
trapSocket.on('error', () => {});
|
|
705
|
+
});
|
|
641
706
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
707
|
+
return new Promise((resolve, reject) => {
|
|
708
|
+
server.on('error', reject);
|
|
709
|
+
server.listen(0, '127.0.0.1', () => {
|
|
710
|
+
server.removeListener('error', reject);
|
|
711
|
+
const address = server.address() as AddressInfo;
|
|
712
|
+
resolve({ server, port: address.port, trapLines });
|
|
713
|
+
});
|
|
714
|
+
});
|
|
649
715
|
}
|
|
650
716
|
|
|
651
717
|
function buildWrappedCommand(
|
|
@@ -768,6 +834,16 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
768
834
|
const activeBash = new Map<string, BashSandboxState>();
|
|
769
835
|
const notified = new Set<string>();
|
|
770
836
|
const callAllowances = new Set<string>();
|
|
837
|
+
const sessionAllowedReadPaths = new Set<string>();
|
|
838
|
+
const sessionAllowedWritePaths = new Set<string>();
|
|
839
|
+
|
|
840
|
+
function getEffectiveAllowRead(config: SandboxConfig): string[] {
|
|
841
|
+
return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function getEffectiveAllowWrite(config: SandboxConfig): string[] {
|
|
845
|
+
return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
|
|
846
|
+
}
|
|
771
847
|
let enabledNotified = false;
|
|
772
848
|
let sandboxDisabled = false;
|
|
773
849
|
let configuredShell: string | undefined;
|
|
@@ -990,6 +1066,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
990
1066
|
|
|
991
1067
|
activeBash.delete(callID);
|
|
992
1068
|
if (state.stop) await state.stop().catch(() => undefined);
|
|
1069
|
+
if (state.trapServer) {
|
|
1070
|
+
await new Promise<void>((resolve) => {
|
|
1071
|
+
state.trapServer!.close(() => resolve());
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
993
1074
|
rmSync(state.policyDir, { recursive: true, force: true });
|
|
994
1075
|
}
|
|
995
1076
|
|
|
@@ -1034,6 +1115,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1034
1115
|
const effectiveConfig = {
|
|
1035
1116
|
...config,
|
|
1036
1117
|
network: { ...config.network },
|
|
1118
|
+
filesystem: {
|
|
1119
|
+
...config.filesystem,
|
|
1120
|
+
allowRead: getEffectiveAllowRead(config),
|
|
1121
|
+
allowWrite: getEffectiveAllowWrite(config),
|
|
1122
|
+
},
|
|
1037
1123
|
};
|
|
1038
1124
|
|
|
1039
1125
|
if (!allowNetwork) {
|
|
@@ -1067,11 +1153,24 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1067
1153
|
}
|
|
1068
1154
|
|
|
1069
1155
|
const originalCommand = args.command as string;
|
|
1156
|
+
|
|
1157
|
+
// Start a local trap server so landstrip's query traps are answered
|
|
1158
|
+
// in-process instead of relying on the TUI plugin's discovery port.
|
|
1159
|
+
const trapServer = await startTrapServer(
|
|
1160
|
+
effectiveConfig.filesystem.allowRead,
|
|
1161
|
+
effectiveConfig.filesystem.allowWrite,
|
|
1162
|
+
effectiveConfig.filesystem.denyRead,
|
|
1163
|
+
effectiveConfig.filesystem.denyWrite,
|
|
1164
|
+
directory,
|
|
1165
|
+
sessionAllowedReadPaths,
|
|
1166
|
+
sessionAllowedWritePaths,
|
|
1167
|
+
);
|
|
1168
|
+
|
|
1070
1169
|
const wrappedCommand = buildWrappedCommand(
|
|
1071
1170
|
policy.path,
|
|
1072
1171
|
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
1073
1172
|
originalCommand,
|
|
1074
|
-
|
|
1173
|
+
trapServer.port,
|
|
1075
1174
|
);
|
|
1076
1175
|
|
|
1077
1176
|
activeBash.set(callID, {
|
|
@@ -1080,6 +1179,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1080
1179
|
policyDir: policy.dir,
|
|
1081
1180
|
port: proxyPort,
|
|
1082
1181
|
stop: proxy ? proxy.stop : null,
|
|
1182
|
+
trapServer: trapServer.server,
|
|
1183
|
+
trapServerPort: trapServer.port,
|
|
1184
|
+
trapLines: trapServer.trapLines,
|
|
1083
1185
|
});
|
|
1084
1186
|
|
|
1085
1187
|
args.command = wrappedCommand;
|
|
@@ -1108,8 +1210,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1108
1210
|
const patterns = permissionPatterns(request);
|
|
1109
1211
|
|
|
1110
1212
|
const decisions: SandboxPermissionDecision[] = [];
|
|
1111
|
-
const effectiveAllowRead = config
|
|
1112
|
-
const effectiveAllowWrite = config
|
|
1213
|
+
const effectiveAllowRead = getEffectiveAllowRead(config);
|
|
1214
|
+
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1113
1215
|
|
|
1114
1216
|
if (permission === 'read') {
|
|
1115
1217
|
for (const pattern of patterns) {
|
|
@@ -1158,8 +1260,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1158
1260
|
const config = await activeConfig();
|
|
1159
1261
|
if (!config) return;
|
|
1160
1262
|
|
|
1161
|
-
const effectiveAllowRead = config
|
|
1162
|
-
const effectiveAllowWrite = config
|
|
1263
|
+
const effectiveAllowRead = getEffectiveAllowRead(config);
|
|
1264
|
+
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1163
1265
|
|
|
1164
1266
|
if (input.tool === 'bash') {
|
|
1165
1267
|
await prepareBash(input.callID, output.args, config);
|
|
@@ -1223,9 +1325,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1223
1325
|
}
|
|
1224
1326
|
|
|
1225
1327
|
const outputText = output?.output ?? '';
|
|
1226
|
-
// Query traps were already resolved
|
|
1227
|
-
//
|
|
1228
|
-
|
|
1328
|
+
// Query traps were already resolved in-process by the local trap server;
|
|
1329
|
+
// only terminal (info) traps and trap-server-collected lines belong in
|
|
1330
|
+
// the after-the-fact toast.
|
|
1331
|
+
const serverTrapOutput = state.trapLines.join('\n');
|
|
1332
|
+
const combinedOutput = serverTrapOutput ? outputText + '\n' + serverTrapOutput : outputText;
|
|
1333
|
+
const errors = parseLandstripTraps(combinedOutput).filter(
|
|
1229
1334
|
(trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
|
|
1230
1335
|
);
|
|
1231
1336
|
if (errors.length > 0) {
|
|
@@ -1250,9 +1355,19 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1250
1355
|
|
|
1251
1356
|
const blockedPath = extractBlockedPath(outputText, directory, state.originalCommand);
|
|
1252
1357
|
if (blockedPath) {
|
|
1358
|
+
let blockedOperation: 'read' | 'write' = 'read';
|
|
1359
|
+
for (const trap of errors) {
|
|
1360
|
+
if (trap.kind === 'filesystem') {
|
|
1361
|
+
blockedOperation = trap.operation;
|
|
1362
|
+
break;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
const scope = sessionScopeFor(blockedPath, directory);
|
|
1366
|
+
if (blockedOperation === 'read') sessionAllowedReadPaths.add(scope);
|
|
1367
|
+
else sessionAllowedWritePaths.add(scope);
|
|
1253
1368
|
await notifyOnce(
|
|
1254
1369
|
`blocked:${blockedPath}`,
|
|
1255
|
-
`Sandbox blocked
|
|
1370
|
+
`Sandbox blocked ${blockedOperation} to "${blockedPath}". Added "${scope}" to session allowlist; retry the command.`,
|
|
1256
1371
|
'warning',
|
|
1257
1372
|
);
|
|
1258
1373
|
}
|
|
@@ -1346,8 +1461,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1346
1461
|
const config = await activeConfig();
|
|
1347
1462
|
if (!config) return;
|
|
1348
1463
|
|
|
1349
|
-
const effectiveAllowRead = config
|
|
1350
|
-
const effectiveAllowWrite = config
|
|
1464
|
+
const effectiveAllowRead = getEffectiveAllowRead(config);
|
|
1465
|
+
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1351
1466
|
|
|
1352
1467
|
for (const path of extractCandidatePaths(shellCommand)) {
|
|
1353
1468
|
const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
|
package/package.json
CHANGED
package/shared.ts
CHANGED
|
@@ -47,6 +47,52 @@ const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
|
47
47
|
'@landstrip/landstrip-win32-x64',
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
|
+
// Breadth-first filesystem approval: a held read/write under a directory tree
|
|
51
|
+
// is approved for the broadest reasonable ancestor (e.g. `~/.cargo`, not each
|
|
52
|
+
// subcrate file), so a single approval covers sibling files under the same tree.
|
|
53
|
+
export function pathUnderDirectory(filePath: string, dir: string): boolean {
|
|
54
|
+
if (filePath === dir) return true;
|
|
55
|
+
const sep = dir.endsWith('/') ? '' : '/';
|
|
56
|
+
return filePath.startsWith(dir + sep);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sessionAllows(prefixes: Set<string>, filePath: string): boolean {
|
|
60
|
+
for (const prefix of prefixes) {
|
|
61
|
+
if (pathUnderDirectory(filePath, prefix)) return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The broadest ancestor worth approving in one click: the immediate child of
|
|
67
|
+
// `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
|
|
68
|
+
// for paths under it, otherwise the containing directory. When the file sits
|
|
69
|
+
// directly on a boundary (so the only ancestor is `$HOME` itself, which would
|
|
70
|
+
// over-broaden), fall back to the exact file so nothing widens silently.
|
|
71
|
+
export function sessionScopeFor(filePath: string, baseDirectory: string): string {
|
|
72
|
+
const dir = dirname(filePath);
|
|
73
|
+
const home = homedir();
|
|
74
|
+
const boundaries = new Set<string>();
|
|
75
|
+
if (home) boundaries.add(home);
|
|
76
|
+
try {
|
|
77
|
+
const realHome = realpathSync.native(home);
|
|
78
|
+
if (realHome) boundaries.add(realHome);
|
|
79
|
+
} catch {
|
|
80
|
+
// $HOME not resolvable — fall back to the raw value only.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const boundary of boundaries) {
|
|
84
|
+
if (pathUnderDirectory(dir, boundary)) {
|
|
85
|
+
const rest = dir.slice(boundary.length).replace(/^\/+/, '');
|
|
86
|
+
const first = rest.split('/')[0];
|
|
87
|
+
if (!first) return filePath;
|
|
88
|
+
return boundary.endsWith('/') ? boundary + first : `${boundary}/${first}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (pathUnderDirectory(dir, baseDirectory)) return baseDirectory;
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
51
97
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
52
98
|
}
|
package/tui.ts
CHANGED
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
4
|
import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
|
|
5
|
-
import { realpathSync } from 'node:fs';
|
|
6
5
|
import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
|
|
7
|
-
import { homedir } from 'node:os';
|
|
8
|
-
import { dirname } from 'node:path';
|
|
9
6
|
|
|
10
7
|
import {
|
|
11
8
|
getConfigPaths,
|
|
@@ -15,7 +12,9 @@ import {
|
|
|
15
12
|
permissionLabel,
|
|
16
13
|
permissionResource,
|
|
17
14
|
removeDiscoveryFile,
|
|
15
|
+
sessionAllows,
|
|
18
16
|
sandboxSummary,
|
|
17
|
+
sessionScopeFor,
|
|
19
18
|
updateForPermission,
|
|
20
19
|
writeConfigFile,
|
|
21
20
|
writeDiscoveryPort,
|
|
@@ -67,54 +66,6 @@ function permissionDetail(permission: PendingPermission): string {
|
|
|
67
66
|
return resource && !label.includes(resource) ? `${label}: ${resource}` : label;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
// Breadth-first filesystem approval: a held read/write under a directory tree
|
|
71
|
-
// is approved for the broadest reasonable ancestor (e.g. `~/.cargo`, not each
|
|
72
|
-
// subcrate file), so a single scan does not spawn one dialog per file. The
|
|
73
|
-
// session set stores directory prefixes and is matched with separator-safe
|
|
74
|
-
// prefix logic so a sibling file under an approved scope is auto-allowed.
|
|
75
|
-
function pathUnderDirectory(filePath: string, dir: string): boolean {
|
|
76
|
-
if (filePath === dir) return true;
|
|
77
|
-
const sep = dir.endsWith('/') ? '' : '/';
|
|
78
|
-
return filePath.startsWith(dir + sep);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function sessionAllows(prefixes: Set<string>, filePath: string): boolean {
|
|
82
|
-
for (const prefix of prefixes) {
|
|
83
|
-
if (pathUnderDirectory(filePath, prefix)) return true;
|
|
84
|
-
}
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// The broadest ancestor worth approving in one click: the immediate child of
|
|
89
|
-
// `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
|
|
90
|
-
// for paths under it, otherwise the containing directory. When the file sits
|
|
91
|
-
// directly on a boundary (so the only ancestor is `$HOME` itself, which would
|
|
92
|
-
// over-broaden), fall back to the exact file so nothing widens silently.
|
|
93
|
-
function sessionScopeFor(filePath: string, baseDirectory: string): string {
|
|
94
|
-
const dir = dirname(filePath);
|
|
95
|
-
const home = homedir();
|
|
96
|
-
const boundaries = new Set<string>();
|
|
97
|
-
if (home) boundaries.add(home);
|
|
98
|
-
try {
|
|
99
|
-
const realHome = realpathSync.native(home);
|
|
100
|
-
if (realHome) boundaries.add(realHome);
|
|
101
|
-
} catch {
|
|
102
|
-
// $HOME not resolvable — fall back to the raw value only.
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
for (const boundary of boundaries) {
|
|
106
|
-
if (pathUnderDirectory(dir, boundary)) {
|
|
107
|
-
const rest = dir.slice(boundary.length).replace(/^\/+/, '');
|
|
108
|
-
const first = rest.split('/')[0];
|
|
109
|
-
if (!first) return filePath;
|
|
110
|
-
return boundary.endsWith('/') ? boundary + first : `${boundary}/${first}`;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (pathUnderDirectory(dir, baseDirectory)) return baseDirectory;
|
|
115
|
-
return dir;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
69
|
const tui: TuiPlugin = async (api, options, meta) => {
|
|
119
70
|
const optionOverrides = normalizeOptions(options);
|
|
120
71
|
|