opencode-landstrip 0.16.13 → 0.16.15
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 +33 -7
- package/package.json +1 -1
- package/shared.ts +64 -32
- package/tui.ts +2 -51
package/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
permissionType,
|
|
27
27
|
readDiscoveryPort,
|
|
28
28
|
sandboxSummary,
|
|
29
|
+
sessionScopeFor,
|
|
29
30
|
} from './shared.js';
|
|
30
31
|
|
|
31
32
|
interface LandstripPolicy {
|
|
@@ -768,6 +769,16 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
768
769
|
const activeBash = new Map<string, BashSandboxState>();
|
|
769
770
|
const notified = new Set<string>();
|
|
770
771
|
const callAllowances = new Set<string>();
|
|
772
|
+
const sessionAllowedReadPaths = new Set<string>();
|
|
773
|
+
const sessionAllowedWritePaths = new Set<string>();
|
|
774
|
+
|
|
775
|
+
function getEffectiveAllowRead(config: SandboxConfig): string[] {
|
|
776
|
+
return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function getEffectiveAllowWrite(config: SandboxConfig): string[] {
|
|
780
|
+
return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
|
|
781
|
+
}
|
|
771
782
|
let enabledNotified = false;
|
|
772
783
|
let sandboxDisabled = false;
|
|
773
784
|
let configuredShell: string | undefined;
|
|
@@ -1034,6 +1045,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1034
1045
|
const effectiveConfig = {
|
|
1035
1046
|
...config,
|
|
1036
1047
|
network: { ...config.network },
|
|
1048
|
+
filesystem: {
|
|
1049
|
+
...config.filesystem,
|
|
1050
|
+
allowRead: getEffectiveAllowRead(config),
|
|
1051
|
+
allowWrite: getEffectiveAllowWrite(config),
|
|
1052
|
+
},
|
|
1037
1053
|
};
|
|
1038
1054
|
|
|
1039
1055
|
if (!allowNetwork) {
|
|
@@ -1108,8 +1124,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1108
1124
|
const patterns = permissionPatterns(request);
|
|
1109
1125
|
|
|
1110
1126
|
const decisions: SandboxPermissionDecision[] = [];
|
|
1111
|
-
const effectiveAllowRead = config
|
|
1112
|
-
const effectiveAllowWrite = config
|
|
1127
|
+
const effectiveAllowRead = getEffectiveAllowRead(config);
|
|
1128
|
+
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1113
1129
|
|
|
1114
1130
|
if (permission === 'read') {
|
|
1115
1131
|
for (const pattern of patterns) {
|
|
@@ -1158,8 +1174,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1158
1174
|
const config = await activeConfig();
|
|
1159
1175
|
if (!config) return;
|
|
1160
1176
|
|
|
1161
|
-
const effectiveAllowRead = config
|
|
1162
|
-
const effectiveAllowWrite = config
|
|
1177
|
+
const effectiveAllowRead = getEffectiveAllowRead(config);
|
|
1178
|
+
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1163
1179
|
|
|
1164
1180
|
if (input.tool === 'bash') {
|
|
1165
1181
|
await prepareBash(input.callID, output.args, config);
|
|
@@ -1250,9 +1266,19 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1250
1266
|
|
|
1251
1267
|
const blockedPath = extractBlockedPath(outputText, directory, state.originalCommand);
|
|
1252
1268
|
if (blockedPath) {
|
|
1269
|
+
let blockedOperation: 'read' | 'write' = 'read';
|
|
1270
|
+
for (const trap of errors) {
|
|
1271
|
+
if (trap.kind === 'filesystem') {
|
|
1272
|
+
blockedOperation = trap.operation;
|
|
1273
|
+
break;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
const scope = sessionScopeFor(blockedPath, directory);
|
|
1277
|
+
if (blockedOperation === 'read') sessionAllowedReadPaths.add(scope);
|
|
1278
|
+
else sessionAllowedWritePaths.add(scope);
|
|
1253
1279
|
await notifyOnce(
|
|
1254
1280
|
`blocked:${blockedPath}`,
|
|
1255
|
-
`Sandbox blocked
|
|
1281
|
+
`Sandbox blocked ${blockedOperation} to "${blockedPath}". Added "${scope}" to session allowlist; retry the command.`,
|
|
1256
1282
|
'warning',
|
|
1257
1283
|
);
|
|
1258
1284
|
}
|
|
@@ -1346,8 +1372,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1346
1372
|
const config = await activeConfig();
|
|
1347
1373
|
if (!config) return;
|
|
1348
1374
|
|
|
1349
|
-
const effectiveAllowRead = config
|
|
1350
|
-
const effectiveAllowWrite = config
|
|
1375
|
+
const effectiveAllowRead = getEffectiveAllowRead(config);
|
|
1376
|
+
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1351
1377
|
|
|
1352
1378
|
for (const path of extractCandidatePaths(shellCommand)) {
|
|
1353
1379
|
const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
|
package/package.json
CHANGED
package/shared.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { binaryPath } from '@landstrip/landstrip';
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { homedir, tmpdir } from 'node:os';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { dirname, join } from 'node:path';
|
|
10
11
|
|
|
11
12
|
export interface SandboxFilesystemConfig {
|
|
@@ -36,30 +37,7 @@ export interface SandboxConfigOverrides {
|
|
|
36
37
|
filesystem?: Partial<SandboxFilesystemConfig>;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
enabled: true,
|
|
41
|
-
network: {
|
|
42
|
-
allowNetwork: false,
|
|
43
|
-
allowLocalBinding: false,
|
|
44
|
-
allowAllUnixSockets: false,
|
|
45
|
-
allowUnixSockets: [],
|
|
46
|
-
allowedDomains: [],
|
|
47
|
-
deniedDomains: [],
|
|
48
|
-
},
|
|
49
|
-
filesystem: {
|
|
50
|
-
denyRead: ['/Users', '/home'],
|
|
51
|
-
allowRead: ['.', '~/.gitconfig', '~/.config/git/config', '/dev/null'],
|
|
52
|
-
allowWrite: ['.', '/dev/null'],
|
|
53
|
-
denyWrite: [
|
|
54
|
-
'**/.env',
|
|
55
|
-
'**/.env.*',
|
|
56
|
-
'**/*.pem',
|
|
57
|
-
'**/*.key',
|
|
58
|
-
'.opencode/sandbox.json',
|
|
59
|
-
'~/.config/opencode/sandbox.json',
|
|
60
|
-
],
|
|
61
|
-
},
|
|
62
|
-
};
|
|
40
|
+
const packageDir = dirname(fileURLToPath(import.meta.url));
|
|
63
41
|
|
|
64
42
|
const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
65
43
|
'@landstrip/landstrip',
|
|
@@ -69,6 +47,52 @@ const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
|
69
47
|
'@landstrip/landstrip-win32-x64',
|
|
70
48
|
]);
|
|
71
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
|
+
|
|
72
96
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
97
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
74
98
|
}
|
|
@@ -195,13 +219,18 @@ export function loadConfig(
|
|
|
195
219
|
optionOverrides: SandboxConfigOverrides,
|
|
196
220
|
): SandboxConfig {
|
|
197
221
|
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
),
|
|
203
|
-
|
|
204
|
-
|
|
222
|
+
const templatePath = join(packageDir, 'sandbox.json');
|
|
223
|
+
|
|
224
|
+
if (!existsSync(globalPath)) {
|
|
225
|
+
mkdirSync(dirname(globalPath), { recursive: true });
|
|
226
|
+
writeFileSync(globalPath, readFileSync(templatePath, 'utf-8'), 'utf-8');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const templateConfig: SandboxConfig = JSON.parse(readFileSync(templatePath, 'utf-8'));
|
|
230
|
+
const globalOverrides = readConfigFile(globalPath) ?? {};
|
|
231
|
+
const baseConfig = deepMerge(templateConfig, globalOverrides);
|
|
232
|
+
|
|
233
|
+
return deepMerge(deepMerge(baseConfig, readConfigFile(projectPath) ?? {}), optionOverrides);
|
|
205
234
|
}
|
|
206
235
|
|
|
207
236
|
export function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
|
|
@@ -210,7 +239,10 @@ export function writeConfigFile(configPath: string, update: SandboxConfigOverrid
|
|
|
210
239
|
throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
|
|
211
240
|
}
|
|
212
241
|
|
|
213
|
-
const
|
|
242
|
+
const templateConfig: SandboxConfig = JSON.parse(
|
|
243
|
+
readFileSync(join(packageDir, 'sandbox.json'), 'utf-8'),
|
|
244
|
+
);
|
|
245
|
+
const next = deepMerge(deepMerge(templateConfig, current), update);
|
|
214
246
|
|
|
215
247
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
216
248
|
writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
|
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
|
|