opencode-landstrip 0.19.6 → 0.19.8
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/README.md +7 -6
- package/index.ts +75 -215
- package/package.json +3 -3
- package/sandbox.json +3 -1
- package/shared.ts +73 -173
- package/tui.ts +2 -0
package/README.md
CHANGED
|
@@ -19,17 +19,18 @@ Approval never bypasses hard denials. Interactive requests without a live
|
|
|
19
19
|
presenter remain denied. Only AI `bash` receives OS isolation: OpenCode's plugin
|
|
20
20
|
API cannot replace direct user shell commands.
|
|
21
21
|
|
|
22
|
-
Policy precedence (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
3. `.opencode/sandbox.json`.
|
|
27
|
-
4. Plugin options.
|
|
22
|
+
Policy precedence: bundled [`sandbox.json`](sandbox.json) → global →
|
|
23
|
+
`.opencode/sandbox.json` → plugin options. The global file is
|
|
24
|
+
`$XDG_CONFIG_HOME/opencode/sandbox.json` (`~/.config` when unset, empty, or relative).
|
|
25
|
+
Persistent approvals update the chosen project/global file.
|
|
28
26
|
|
|
29
27
|
Arrays combine; later scalar values win. Defaults allow project writes, deny
|
|
30
28
|
sensitive-file writes, and block network access. Enabled sandboxing fails closed
|
|
31
29
|
on unusable binaries/platforms; `enabled: false` explicitly permits unsandboxed Bash.
|
|
32
30
|
|
|
31
|
+
`filesystem.denyReadAlways` and `filesystem.denyWriteAlways` block approvals and
|
|
32
|
+
equal/nested grants. Non-empty hard-denial lists are unsupported on Windows.
|
|
33
|
+
|
|
33
34
|
See [landstrip(1)](../landstrip/man/man1/landstrip.1) for native CLI/policy semantics.
|
|
34
35
|
Runtime seccomp traps/query approval are Linux-only. Native macOS `--trap` reports
|
|
35
36
|
setup/launch errors only and closes on successful exec—not runtime violations.
|
package/index.ts
CHANGED
|
@@ -10,39 +10,38 @@ import { type AddressInfo, connect as connectNet, createServer } from 'node:net'
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { basename, join } from 'node:path';
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
createProxyCredentials,
|
|
15
|
+
createProxyEnvironment,
|
|
16
|
+
startFilterProxy,
|
|
17
|
+
} from '@landstrip/landstrip-api/proxy';
|
|
14
18
|
|
|
15
19
|
import {
|
|
16
20
|
type LandstripTrap,
|
|
17
21
|
type SandboxConfig,
|
|
18
|
-
type SandboxFilesystemConfig,
|
|
19
22
|
allowsAllDomains,
|
|
20
|
-
canonicalizeGlobPattern,
|
|
21
23
|
canonicalizePath,
|
|
22
24
|
controlResponseLine,
|
|
23
25
|
decodeLandstripTrap,
|
|
24
|
-
|
|
26
|
+
evaluateDomainAccess,
|
|
27
|
+
evaluateReadAccess,
|
|
28
|
+
evaluateWriteAccess,
|
|
25
29
|
extractDomainsFromCommand,
|
|
30
|
+
extractDeniedPath,
|
|
26
31
|
formatLandstripTraps,
|
|
27
32
|
getConfigPaths,
|
|
28
|
-
|
|
33
|
+
isDomainAllowed,
|
|
29
34
|
isRecord,
|
|
35
|
+
buildLandstripPolicy,
|
|
30
36
|
landstripBinaryPath,
|
|
31
37
|
loadConfig,
|
|
32
38
|
normalizeOptions,
|
|
33
|
-
normalizePathSeparators,
|
|
34
39
|
parseLandstripTraps,
|
|
35
40
|
readDiscoveryPort,
|
|
41
|
+
serializeLandstripPolicy,
|
|
36
42
|
trapSessionHelloLine,
|
|
37
43
|
} from './shared.js';
|
|
38
44
|
|
|
39
|
-
type LandstripPolicy = {
|
|
40
|
-
network: Omit<SandboxConfig['network'], 'allowedDomains' | 'deniedDomains'> & {
|
|
41
|
-
httpProxyPort?: number;
|
|
42
|
-
};
|
|
43
|
-
filesystem: SandboxFilesystemConfig;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
45
|
interface BashSandboxState {
|
|
47
46
|
originalCommand: string;
|
|
48
47
|
wrappedCommand: string;
|
|
@@ -72,148 +71,6 @@ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
|
72
71
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
73
72
|
const DISCOVERY_CONNECT_TIMEOUT_MS = 250;
|
|
74
73
|
|
|
75
|
-
function normalizePathForMatch(filePath: string): string {
|
|
76
|
-
return process.platform === 'win32' ? normalizePathSeparators(filePath).toLowerCase() : filePath;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Component count of an absolute path; "/" is 0. Read rules use it to rank
|
|
80
|
-
// matching allow and deny patterns by specificity.
|
|
81
|
-
function pathDepth(absolutePath: string): number {
|
|
82
|
-
return absolutePath.split('/').filter((segment) => segment.length > 0).length;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// The depth of the most specific pattern that matches `filePath`, or -1 when
|
|
86
|
-
// none match. A glob is anchored to the whole path, so it ranks at the path's
|
|
87
|
-
// own depth; a literal pattern ranks at the depth of the prefix it covers.
|
|
88
|
-
function matchDepth(filePath: string, patterns: string[], baseDirectory: string): number {
|
|
89
|
-
const abs = normalizePathForMatch(canonicalizePath(filePath, baseDirectory));
|
|
90
|
-
let depth = -1;
|
|
91
|
-
|
|
92
|
-
for (const pattern of patterns) {
|
|
93
|
-
if (/[*?[\]]/.test(pattern)) {
|
|
94
|
-
const absPattern = normalizePathForMatch(canonicalizeGlobPattern(pattern, baseDirectory));
|
|
95
|
-
if (globToRegExp(absPattern).test(abs)) depth = Math.max(depth, pathDepth(abs));
|
|
96
|
-
} else {
|
|
97
|
-
const absPattern = normalizePathForMatch(canonicalizePath(pattern, baseDirectory));
|
|
98
|
-
const separator = absPattern.endsWith('/') ? '' : '/';
|
|
99
|
-
if (abs === absPattern || abs.startsWith(absPattern + separator)) {
|
|
100
|
-
depth = Math.max(depth, pathDepth(absPattern));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return depth;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function resolveFilesystemPatterns(patterns: string[], baseDirectory: string): string[] {
|
|
109
|
-
return patterns.map((pattern) =>
|
|
110
|
-
/[*?[\]]/.test(pattern)
|
|
111
|
-
? canonicalizeGlobPattern(pattern, baseDirectory)
|
|
112
|
-
: canonicalizePath(pattern, baseDirectory),
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function resolveFilesystemConfig(
|
|
117
|
-
config: SandboxFilesystemConfig,
|
|
118
|
-
baseDirectory: string,
|
|
119
|
-
): SandboxFilesystemConfig {
|
|
120
|
-
return {
|
|
121
|
-
denyRead: resolveFilesystemPatterns(config.denyRead, baseDirectory),
|
|
122
|
-
allowRead: resolveFilesystemPatterns(config.allowRead, baseDirectory),
|
|
123
|
-
allowWrite: resolveFilesystemPatterns(config.allowWrite, baseDirectory),
|
|
124
|
-
denyWrite: resolveFilesystemPatterns(config.denyWrite, baseDirectory),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function isDomainAllowed(domain: string, config: SandboxConfig): boolean {
|
|
129
|
-
return (
|
|
130
|
-
config.network.allowNetwork ||
|
|
131
|
-
(!domainMatchesAny(domain, config.network.deniedDomains) &&
|
|
132
|
-
domainMatchesAny(domain, config.network.allowedDomains))
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function isReadAllowed(
|
|
137
|
-
path: string,
|
|
138
|
-
allowPatterns: string[],
|
|
139
|
-
denyPatterns: string[],
|
|
140
|
-
baseDirectory: string,
|
|
141
|
-
): boolean {
|
|
142
|
-
const allowDepth = matchDepth(path, allowPatterns, baseDirectory);
|
|
143
|
-
const denyDepth = matchDepth(path, denyPatterns, baseDirectory);
|
|
144
|
-
return allowDepth >= 0 && allowDepth >= denyDepth;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function writeAccess(
|
|
148
|
-
path: string,
|
|
149
|
-
allowPatterns: string[],
|
|
150
|
-
denyPatterns: string[],
|
|
151
|
-
baseDirectory: string,
|
|
152
|
-
): 'allow' | 'deny' | 'unlisted' {
|
|
153
|
-
if (matchDepth(path, denyPatterns, baseDirectory) >= 0) return 'deny';
|
|
154
|
-
return matchDepth(path, allowPatterns, baseDirectory) >= 0 ? 'allow' : 'unlisted';
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function extractCandidatePaths(command: string): string[] {
|
|
158
|
-
const paths: string[] = [];
|
|
159
|
-
const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/g) ?? [];
|
|
160
|
-
for (const token of tokens) {
|
|
161
|
-
const clean = token.replace(/^["']|["']$/g, '').replace(/[,;]$/, '');
|
|
162
|
-
if (
|
|
163
|
-
clean.startsWith('/') ||
|
|
164
|
-
clean.startsWith('~/') ||
|
|
165
|
-
clean === '~' ||
|
|
166
|
-
clean.startsWith('./') ||
|
|
167
|
-
clean.startsWith('../')
|
|
168
|
-
) {
|
|
169
|
-
paths.push(clean);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return paths;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function extractBlockedPath(
|
|
176
|
-
output: string,
|
|
177
|
-
baseDirectory: string,
|
|
178
|
-
command?: string,
|
|
179
|
-
): string | null {
|
|
180
|
-
// bash/sh: line X: /path: Permission denied
|
|
181
|
-
let match = output.match(
|
|
182
|
-
/(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
|
|
183
|
-
);
|
|
184
|
-
if (match?.[1]) return canonicalizePath(match[1], baseDirectory);
|
|
185
|
-
|
|
186
|
-
// ls/cat/cp: cannot open/access/stat '/path': Permission denied
|
|
187
|
-
match = output.match(
|
|
188
|
-
/^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
|
|
189
|
-
);
|
|
190
|
-
if (match?.[1]) return canonicalizePath(match[1], baseDirectory);
|
|
191
|
-
|
|
192
|
-
// Generic: cmd: /absolute/path: Permission denied or Operation not permitted
|
|
193
|
-
match = output.match(
|
|
194
|
-
/^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
|
|
195
|
-
);
|
|
196
|
-
if (match?.[1]) return canonicalizePath(match[1], baseDirectory);
|
|
197
|
-
|
|
198
|
-
// Landstrip structured trap format carrying a denied path
|
|
199
|
-
const landstripTraps = parseLandstripTraps(output);
|
|
200
|
-
for (const trap of landstripTraps) {
|
|
201
|
-
if (trap.kind === 'filesystem') return canonicalizePath(trap.path, baseDirectory);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
landstripTraps.some((trap) => trap.kind === 'filesystem' || trap.kind === 'internal') &&
|
|
206
|
-
command
|
|
207
|
-
) {
|
|
208
|
-
for (const candidate of extractCandidatePaths(command)) {
|
|
209
|
-
const resolved = canonicalizePath(candidate, baseDirectory);
|
|
210
|
-
return resolved;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
74
|
function evaluateReadPermission(
|
|
218
75
|
path: string,
|
|
219
76
|
config: SandboxConfig,
|
|
@@ -221,13 +78,22 @@ function evaluateReadPermission(
|
|
|
221
78
|
effectiveAllowRead: string[],
|
|
222
79
|
): SandboxPermissionDecision {
|
|
223
80
|
const filePath = canonicalizePath(path, baseDirectory);
|
|
81
|
+
const decision = evaluateReadAccess(filePath, config.filesystem, {
|
|
82
|
+
baseDirectory,
|
|
83
|
+
allowReadOverrides: effectiveAllowRead,
|
|
84
|
+
requireAllowMatch: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (decision === 'deny') {
|
|
88
|
+
return {
|
|
89
|
+
status: 'deny',
|
|
90
|
+
kind: 'read',
|
|
91
|
+
resource: filePath,
|
|
92
|
+
message: `Sandbox: read access denied for "${filePath}" (denyReadAlways).`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
224
95
|
|
|
225
|
-
|
|
226
|
-
// allowRead at least as specifically as any denyRead is allowed silently;
|
|
227
|
-
// everything else asks for approval (allow once/session/persist or reject)
|
|
228
|
-
// rather than being blocked outright. denyRead still hard-applies to bash
|
|
229
|
-
// through the landstrip binary policy, which has no way to prompt.
|
|
230
|
-
if (isReadAllowed(filePath, effectiveAllowRead, config.filesystem.denyRead, baseDirectory)) {
|
|
96
|
+
if (decision === 'allow') {
|
|
231
97
|
return { status: 'allow', kind: 'read', resource: filePath, message: '' };
|
|
232
98
|
}
|
|
233
99
|
|
|
@@ -246,14 +112,21 @@ function evaluateWritePermission(
|
|
|
246
112
|
effectiveAllowWrite: string[],
|
|
247
113
|
): SandboxPermissionDecision {
|
|
248
114
|
const filePath = canonicalizePath(path, baseDirectory);
|
|
249
|
-
const
|
|
250
|
-
filePath,
|
|
251
|
-
effectiveAllowWrite,
|
|
252
|
-
config.filesystem.denyWrite,
|
|
115
|
+
const decision = evaluateWriteAccess(filePath, config.filesystem, {
|
|
253
116
|
baseDirectory,
|
|
254
|
-
|
|
117
|
+
allowWriteOverrides: effectiveAllowWrite,
|
|
118
|
+
});
|
|
255
119
|
|
|
256
|
-
if (
|
|
120
|
+
if (decision === 'denyAlways') {
|
|
121
|
+
return {
|
|
122
|
+
status: 'deny',
|
|
123
|
+
kind: 'write',
|
|
124
|
+
resource: filePath,
|
|
125
|
+
message: `Sandbox: write access denied for "${filePath}" (denyWriteAlways).`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (decision === 'deny') {
|
|
257
130
|
return {
|
|
258
131
|
status: 'deny',
|
|
259
132
|
kind: 'write',
|
|
@@ -262,7 +135,7 @@ function evaluateWritePermission(
|
|
|
262
135
|
};
|
|
263
136
|
}
|
|
264
137
|
|
|
265
|
-
if (
|
|
138
|
+
if (decision === 'allow') {
|
|
266
139
|
return { status: 'allow', kind: 'write', resource: filePath, message: '' };
|
|
267
140
|
}
|
|
268
141
|
|
|
@@ -278,11 +151,9 @@ function evaluateDomainPermission(
|
|
|
278
151
|
domain: string,
|
|
279
152
|
config: SandboxConfig,
|
|
280
153
|
): SandboxPermissionDecision {
|
|
281
|
-
|
|
282
|
-
return { status: 'allow', kind: 'domain', resource: domain, message: '' };
|
|
283
|
-
}
|
|
154
|
+
const decision = evaluateDomainAccess(domain, config.network);
|
|
284
155
|
|
|
285
|
-
if (
|
|
156
|
+
if (decision === 'deny') {
|
|
286
157
|
return {
|
|
287
158
|
status: 'deny',
|
|
288
159
|
kind: 'domain',
|
|
@@ -291,7 +162,7 @@ function evaluateDomainPermission(
|
|
|
291
162
|
};
|
|
292
163
|
}
|
|
293
164
|
|
|
294
|
-
if (
|
|
165
|
+
if (decision === 'allow') {
|
|
295
166
|
return { status: 'allow', kind: 'domain', resource: domain, message: '' };
|
|
296
167
|
}
|
|
297
168
|
|
|
@@ -334,23 +205,6 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
334
205
|
return true;
|
|
335
206
|
}
|
|
336
207
|
|
|
337
|
-
function buildLandstripPolicy(
|
|
338
|
-
config: SandboxConfig,
|
|
339
|
-
baseDirectory: string,
|
|
340
|
-
proxyPort: number | null,
|
|
341
|
-
): LandstripPolicy {
|
|
342
|
-
return {
|
|
343
|
-
network: {
|
|
344
|
-
allowNetwork: config.network.allowNetwork,
|
|
345
|
-
allowLocalBinding: config.network.allowLocalBinding,
|
|
346
|
-
allowAllUnixSockets: config.network.allowAllUnixSockets,
|
|
347
|
-
allowUnixSockets: config.network.allowUnixSockets,
|
|
348
|
-
...(proxyPort !== null ? { httpProxyPort: proxyPort } : {}),
|
|
349
|
-
},
|
|
350
|
-
filesystem: resolveFilesystemConfig(config.filesystem, baseDirectory),
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
208
|
function writePolicyFile(
|
|
355
209
|
config: SandboxConfig,
|
|
356
210
|
baseDirectory: string,
|
|
@@ -360,7 +214,14 @@ function writePolicyFile(
|
|
|
360
214
|
const path = join(dir, 'policy.json');
|
|
361
215
|
writeFileSync(
|
|
362
216
|
path,
|
|
363
|
-
|
|
217
|
+
serializeLandstripPolicy(
|
|
218
|
+
buildLandstripPolicy({
|
|
219
|
+
network: config.network,
|
|
220
|
+
filesystem: config.filesystem,
|
|
221
|
+
baseDirectory,
|
|
222
|
+
httpProxyPort: proxyPort,
|
|
223
|
+
}),
|
|
224
|
+
),
|
|
364
225
|
);
|
|
365
226
|
|
|
366
227
|
return { dir, path };
|
|
@@ -371,19 +232,7 @@ function proxyEnv(
|
|
|
371
232
|
proxyToken?: string | null,
|
|
372
233
|
): Record<string, string> | undefined {
|
|
373
234
|
if (port === null) return undefined;
|
|
374
|
-
|
|
375
|
-
const url = `http://${credentials}127.0.0.1:${port}`;
|
|
376
|
-
|
|
377
|
-
return {
|
|
378
|
-
HTTP_PROXY: url,
|
|
379
|
-
HTTPS_PROXY: url,
|
|
380
|
-
ALL_PROXY: url,
|
|
381
|
-
http_proxy: url,
|
|
382
|
-
https_proxy: url,
|
|
383
|
-
all_proxy: url,
|
|
384
|
-
NO_PROXY: '',
|
|
385
|
-
no_proxy: '',
|
|
386
|
-
};
|
|
235
|
+
return createProxyEnvironment(port, proxyToken) as Record<string, string>;
|
|
387
236
|
}
|
|
388
237
|
|
|
389
238
|
function shellQuote(value: string): string {
|
|
@@ -406,6 +255,8 @@ function startTrapServer(
|
|
|
406
255
|
denyRead: string[],
|
|
407
256
|
denyWrite: string[],
|
|
408
257
|
baseDirectory: string,
|
|
258
|
+
denyReadAlways: string[] = [],
|
|
259
|
+
denyWriteAlways: string[] = [],
|
|
409
260
|
): Promise<{ server: ReturnType<typeof createServer>; port: number; trapLines: string[] }> {
|
|
410
261
|
const trapLines: string[] = [];
|
|
411
262
|
const server = createServer((trapSocket) => {
|
|
@@ -435,8 +286,16 @@ function startTrapServer(
|
|
|
435
286
|
const operation = trap.operation;
|
|
436
287
|
const allowed =
|
|
437
288
|
operation === 'read'
|
|
438
|
-
?
|
|
439
|
-
|
|
289
|
+
? evaluateReadAccess(
|
|
290
|
+
path,
|
|
291
|
+
{ allowRead: effectiveAllowRead, denyRead, denyReadAlways },
|
|
292
|
+
{ baseDirectory, requireAllowMatch: true },
|
|
293
|
+
) === 'allow'
|
|
294
|
+
: evaluateWriteAccess(
|
|
295
|
+
path,
|
|
296
|
+
{ allowWrite: effectiveAllowWrite, denyWrite, denyWriteAlways },
|
|
297
|
+
{ baseDirectory },
|
|
298
|
+
) === 'allow';
|
|
440
299
|
if (allowed) {
|
|
441
300
|
trapSocket.write(controlResponseLine(queryId, 'allow'));
|
|
442
301
|
} else {
|
|
@@ -826,16 +685,13 @@ const plugin: Plugin.Plugin = {
|
|
|
826
685
|
}
|
|
827
686
|
}
|
|
828
687
|
|
|
829
|
-
const
|
|
830
|
-
const
|
|
831
|
-
proxyToken === null
|
|
832
|
-
? undefined
|
|
833
|
-
: `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString('base64')}`;
|
|
688
|
+
const proxyCredentials = allowNetwork ? null : createProxyCredentials();
|
|
689
|
+
const proxyToken = proxyCredentials ? proxyCredentials.token : null;
|
|
834
690
|
const proxy = allowNetwork
|
|
835
691
|
? null
|
|
836
692
|
: await startFilterProxy({
|
|
837
693
|
isDomainAllowed: (domain) => isDomainAllowed(domain, config),
|
|
838
|
-
...(
|
|
694
|
+
...(proxyCredentials ? { authorization: proxyCredentials.authorization } : {}),
|
|
839
695
|
});
|
|
840
696
|
const proxyPort = proxy ? proxy.port : null;
|
|
841
697
|
let policy: { dir: string; path: string };
|
|
@@ -866,6 +722,8 @@ const plugin: Plugin.Plugin = {
|
|
|
866
722
|
config.filesystem.denyRead,
|
|
867
723
|
config.filesystem.denyWrite,
|
|
868
724
|
directory,
|
|
725
|
+
config.filesystem.denyReadAlways,
|
|
726
|
+
config.filesystem.denyWriteAlways,
|
|
869
727
|
)
|
|
870
728
|
: null;
|
|
871
729
|
const trapPort = tuiTrapPort ?? trapServer?.port ?? null;
|
|
@@ -910,11 +768,13 @@ const plugin: Plugin.Plugin = {
|
|
|
910
768
|
const effectiveAllowWrite = config.filesystem.allowWrite;
|
|
911
769
|
const args: Record<string, unknown> = { ...evaluation.metadata };
|
|
912
770
|
if (action === 'read' || action === 'external_directory') args.paths = patterns;
|
|
913
|
-
if (action === 'edit') {
|
|
771
|
+
if (action === 'write' || action === 'edit') {
|
|
914
772
|
args.paths =
|
|
915
773
|
patterns.length > 0
|
|
916
774
|
? patterns
|
|
917
|
-
: [args.filepath].filter(
|
|
775
|
+
: [args.filepath ?? args.path].filter(
|
|
776
|
+
(path): path is string => typeof path === 'string',
|
|
777
|
+
);
|
|
918
778
|
}
|
|
919
779
|
if ((action === 'shell' || action === 'bash') && typeof args.command !== 'string') {
|
|
920
780
|
args.command = patterns[0];
|
|
@@ -1037,7 +897,7 @@ const plugin: Plugin.Plugin = {
|
|
|
1037
897
|
);
|
|
1038
898
|
const blockedPath = blockedTrap
|
|
1039
899
|
? canonicalizePath(blockedTrap.path, directory)
|
|
1040
|
-
:
|
|
900
|
+
: extractDeniedPath(outputText, { cwd: directory, command: state.originalCommand });
|
|
1041
901
|
if (blockedPath) {
|
|
1042
902
|
const blockedOperation = blockedTrap?.operation ?? 'read';
|
|
1043
903
|
await notifyOnce(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.8",
|
|
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-api": "^0.19.
|
|
51
|
+
"@landstrip/landstrip-api": "^0.19.8",
|
|
52
52
|
"@opentui/core": ">=0.5.12",
|
|
53
53
|
"@opentui/solid": ">=0.5.12",
|
|
54
54
|
"solid-js": "1.9.12"
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@opencode/plugin": "^2.0.18",
|
|
58
58
|
"@opencode/theme": "^2.0.18",
|
|
59
59
|
"@types/node": "^26.1.1",
|
|
60
|
-
"oxfmt": "^0.
|
|
60
|
+
"oxfmt": "^0.71.0",
|
|
61
61
|
"oxlint": "^1.68.0",
|
|
62
62
|
"typescript": "^5.9.3"
|
|
63
63
|
},
|
package/sandbox.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"filesystem": {
|
|
12
12
|
"denyRead": ["/Users", "/home"],
|
|
13
|
+
"denyReadAlways": [],
|
|
13
14
|
"allowRead": [".", "~/.gitconfig", "~/.config/git/config", "/dev/null"],
|
|
14
15
|
"allowWrite": [".", "/dev/null"],
|
|
15
16
|
"denyWrite": [
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
"**/*.key",
|
|
20
21
|
".opencode/sandbox.json",
|
|
21
22
|
"~/.config/opencode/sandbox.json"
|
|
22
|
-
]
|
|
23
|
+
],
|
|
24
|
+
"denyWriteAlways": []
|
|
23
25
|
}
|
|
24
26
|
}
|
package/shared.ts
CHANGED
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
4
|
import { binaryPath } from '@landstrip/landstrip-api';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import {
|
|
6
|
+
deepMergeSandboxConfig,
|
|
7
|
+
extractDomainsFromCommand,
|
|
8
|
+
isRecord,
|
|
9
|
+
mergeArray,
|
|
10
|
+
parseSandboxConfig,
|
|
11
|
+
} from '@landstrip/landstrip-api/shared';
|
|
12
|
+
import type { SandboxConfig, SandboxConfigOverrides } from '@landstrip/landstrip-api/shared';
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
LandstripControlResponse,
|
|
16
|
+
LandstripFilesystemPolicy,
|
|
17
|
+
LandstripNetworkPolicy,
|
|
18
|
+
LandstripPolicy,
|
|
19
|
+
LandstripTrap,
|
|
20
|
+
} from '@landstrip/landstrip-api';
|
|
8
21
|
export {
|
|
22
|
+
buildLandstripPolicy,
|
|
9
23
|
allowsAllDomains,
|
|
10
24
|
canonicalizeGlobPattern,
|
|
11
25
|
canonicalizePath,
|
|
@@ -22,43 +36,46 @@ export {
|
|
|
22
36
|
normalizePathSeparators,
|
|
23
37
|
parseLandstripTraps,
|
|
24
38
|
pathUnderDirectory,
|
|
39
|
+
resolveFilesystemPatterns,
|
|
40
|
+
resolveFilesystemPolicy,
|
|
25
41
|
sessionAllows,
|
|
26
42
|
sessionScopeFor,
|
|
43
|
+
serializeLandstripPolicy,
|
|
44
|
+
deepMergeSandboxConfig,
|
|
45
|
+
mergeArray,
|
|
46
|
+
parseSandboxConfig,
|
|
47
|
+
matchesPathPattern,
|
|
48
|
+
matchPathSpecificity,
|
|
49
|
+
isPathReadAllowed,
|
|
50
|
+
isPathWriteAllowed,
|
|
51
|
+
shouldPromptForWrite,
|
|
52
|
+
evaluateReadAccess,
|
|
53
|
+
evaluateWriteAccess,
|
|
54
|
+
isDomainAllowed,
|
|
55
|
+
evaluateDomainAccess,
|
|
56
|
+
extractDomainsFromCommand,
|
|
57
|
+
extractCandidatePaths,
|
|
58
|
+
extractNativeDeniedPath,
|
|
59
|
+
extractDeniedPath,
|
|
60
|
+
type PathAccessDecision,
|
|
61
|
+
type DomainAccessDecision,
|
|
62
|
+
type EvaluateReadOptions,
|
|
63
|
+
type EvaluateWriteOptions,
|
|
64
|
+
type EvaluateDomainOptions,
|
|
65
|
+
type SandboxConfig,
|
|
66
|
+
type SandboxConfigFile,
|
|
67
|
+
type SandboxConfigOverrides,
|
|
68
|
+
type SandboxFilesystemConfig,
|
|
69
|
+
type SandboxNetworkConfig,
|
|
70
|
+
type SandboxShellConfig,
|
|
71
|
+
type SandboxWindowsConfig,
|
|
27
72
|
} from '@landstrip/landstrip-api/shared';
|
|
28
73
|
|
|
29
74
|
import { createHash } from 'node:crypto';
|
|
30
75
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
76
|
import { homedir, tmpdir } from 'node:os';
|
|
32
77
|
import { fileURLToPath } from 'node:url';
|
|
33
|
-
import { dirname, join } from 'node:path';
|
|
34
|
-
|
|
35
|
-
export interface SandboxFilesystemConfig {
|
|
36
|
-
denyRead: string[];
|
|
37
|
-
allowRead: string[];
|
|
38
|
-
allowWrite: string[];
|
|
39
|
-
denyWrite: string[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface SandboxNetworkConfig {
|
|
43
|
-
allowNetwork: boolean;
|
|
44
|
-
allowLocalBinding: boolean;
|
|
45
|
-
allowAllUnixSockets: boolean;
|
|
46
|
-
allowUnixSockets: string[];
|
|
47
|
-
allowedDomains: string[];
|
|
48
|
-
deniedDomains: string[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface SandboxConfig {
|
|
52
|
-
enabled: boolean;
|
|
53
|
-
network: SandboxNetworkConfig;
|
|
54
|
-
filesystem: SandboxFilesystemConfig;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface SandboxConfigOverrides {
|
|
58
|
-
enabled?: boolean;
|
|
59
|
-
network?: Partial<SandboxNetworkConfig>;
|
|
60
|
-
filesystem?: Partial<SandboxFilesystemConfig>;
|
|
61
|
-
}
|
|
78
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
62
79
|
|
|
63
80
|
const packageDir = dirname(fileURLToPath(import.meta.url));
|
|
64
81
|
|
|
@@ -72,105 +89,8 @@ const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
|
72
89
|
'@landstrip/landstrip-win32-arm64',
|
|
73
90
|
]);
|
|
74
91
|
|
|
75
|
-
function rejectUnknownFields(
|
|
76
|
-
value: Record<string, unknown>,
|
|
77
|
-
fields: readonly string[],
|
|
78
|
-
prefix = '',
|
|
79
|
-
): void {
|
|
80
|
-
for (const field of Object.keys(value)) {
|
|
81
|
-
if (!fields.includes(field)) throw new Error(`unknown sandbox field ${prefix}${field}`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function booleanValue(value: unknown, field: string): boolean | undefined {
|
|
86
|
-
if (value === undefined) return undefined;
|
|
87
|
-
if (typeof value !== 'boolean') throw new Error(`${field} must be a boolean`);
|
|
88
|
-
return value;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function stringArray(value: unknown, field: string): string[] | undefined {
|
|
92
|
-
if (value === undefined) return undefined;
|
|
93
|
-
if (!Array.isArray(value) || [...value].some((item) => typeof item !== 'string')) {
|
|
94
|
-
throw new Error(`${field} must be an array of strings`);
|
|
95
|
-
}
|
|
96
|
-
return [...value];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
|
|
100
|
-
if (value === undefined) return undefined;
|
|
101
|
-
if (!isRecord(value)) throw new Error('network must be an object');
|
|
102
|
-
rejectUnknownFields(
|
|
103
|
-
value,
|
|
104
|
-
[
|
|
105
|
-
'allowNetwork',
|
|
106
|
-
'allowLocalBinding',
|
|
107
|
-
'allowAllUnixSockets',
|
|
108
|
-
'allowUnixSockets',
|
|
109
|
-
'allowedDomains',
|
|
110
|
-
'deniedDomains',
|
|
111
|
-
],
|
|
112
|
-
'network.',
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
const config: Partial<SandboxNetworkConfig> = {};
|
|
116
|
-
const allowNetwork = booleanValue(value.allowNetwork, 'network.allowNetwork');
|
|
117
|
-
if (allowNetwork !== undefined) config.allowNetwork = allowNetwork;
|
|
118
|
-
const allowLocalBinding = booleanValue(value.allowLocalBinding, 'network.allowLocalBinding');
|
|
119
|
-
if (allowLocalBinding !== undefined) config.allowLocalBinding = allowLocalBinding;
|
|
120
|
-
const allowAllUnixSockets = booleanValue(
|
|
121
|
-
value.allowAllUnixSockets,
|
|
122
|
-
'network.allowAllUnixSockets',
|
|
123
|
-
);
|
|
124
|
-
if (allowAllUnixSockets !== undefined) config.allowAllUnixSockets = allowAllUnixSockets;
|
|
125
|
-
|
|
126
|
-
const allowUnixSockets = stringArray(value.allowUnixSockets, 'network.allowUnixSockets');
|
|
127
|
-
if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
|
|
128
|
-
|
|
129
|
-
const allowedDomains = stringArray(value.allowedDomains, 'network.allowedDomains');
|
|
130
|
-
if (allowedDomains) config.allowedDomains = allowedDomains;
|
|
131
|
-
|
|
132
|
-
const deniedDomains = stringArray(value.deniedDomains, 'network.deniedDomains');
|
|
133
|
-
if (deniedDomains) config.deniedDomains = deniedDomains;
|
|
134
|
-
|
|
135
|
-
return config;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
|
|
139
|
-
if (value === undefined) return undefined;
|
|
140
|
-
if (!isRecord(value)) throw new Error('filesystem must be an object');
|
|
141
|
-
rejectUnknownFields(value, ['denyRead', 'allowRead', 'allowWrite', 'denyWrite'], 'filesystem.');
|
|
142
|
-
|
|
143
|
-
const config: Partial<SandboxFilesystemConfig> = {};
|
|
144
|
-
const denyRead = stringArray(value.denyRead, 'filesystem.denyRead');
|
|
145
|
-
if (denyRead) config.denyRead = denyRead;
|
|
146
|
-
|
|
147
|
-
const allowRead = stringArray(value.allowRead, 'filesystem.allowRead');
|
|
148
|
-
if (allowRead) config.allowRead = allowRead;
|
|
149
|
-
|
|
150
|
-
const allowWrite = stringArray(value.allowWrite, 'filesystem.allowWrite');
|
|
151
|
-
if (allowWrite) config.allowWrite = allowWrite;
|
|
152
|
-
|
|
153
|
-
const denyWrite = stringArray(value.denyWrite, 'filesystem.denyWrite');
|
|
154
|
-
if (denyWrite) config.denyWrite = denyWrite;
|
|
155
|
-
|
|
156
|
-
return config;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
92
|
export function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
160
|
-
|
|
161
|
-
rejectUnknownFields(value, ['enabled', 'network', 'filesystem']);
|
|
162
|
-
|
|
163
|
-
const config: SandboxConfigOverrides = {};
|
|
164
|
-
const enabled = booleanValue(value.enabled, 'enabled');
|
|
165
|
-
if (enabled !== undefined) config.enabled = enabled;
|
|
166
|
-
|
|
167
|
-
const network = normalizeNetworkConfig(value.network);
|
|
168
|
-
if (network) config.network = network;
|
|
169
|
-
|
|
170
|
-
const filesystem = normalizeFilesystemConfig(value.filesystem);
|
|
171
|
-
if (filesystem) config.filesystem = filesystem;
|
|
172
|
-
|
|
173
|
-
return config;
|
|
93
|
+
return parseSandboxConfig(value);
|
|
174
94
|
}
|
|
175
95
|
|
|
176
96
|
export function normalizeOptions(options: unknown): SandboxConfigOverrides {
|
|
@@ -180,37 +100,16 @@ export function normalizeOptions(options: unknown): SandboxConfigOverrides {
|
|
|
180
100
|
return normalizeConfig(options.config);
|
|
181
101
|
}
|
|
182
102
|
|
|
183
|
-
function mergeArray(base: string[], override?: string[]): string[] {
|
|
184
|
-
if (!override) return base;
|
|
185
|
-
return [...new Set([...base, ...override])];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
103
|
export function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
189
|
-
|
|
190
|
-
const filesystem = overrides.filesystem;
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
enabled: overrides.enabled ?? base.enabled,
|
|
194
|
-
network: {
|
|
195
|
-
allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
|
|
196
|
-
allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
|
|
197
|
-
allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
|
|
198
|
-
allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
|
|
199
|
-
allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
|
|
200
|
-
deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
|
|
201
|
-
},
|
|
202
|
-
filesystem: {
|
|
203
|
-
denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
|
|
204
|
-
allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
|
|
205
|
-
allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
|
|
206
|
-
denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
|
|
207
|
-
},
|
|
208
|
-
};
|
|
104
|
+
return deepMergeSandboxConfig(base, overrides);
|
|
209
105
|
}
|
|
210
106
|
|
|
211
107
|
export function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
|
|
108
|
+
const configHome = process.env.XDG_CONFIG_HOME;
|
|
109
|
+
const configDirectory =
|
|
110
|
+
configHome && isAbsolute(configHome) ? configHome : join(homedir(), '.config');
|
|
212
111
|
return {
|
|
213
|
-
globalPath: join(
|
|
112
|
+
globalPath: join(configDirectory, 'opencode', 'sandbox.json'),
|
|
214
113
|
projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
|
|
215
114
|
};
|
|
216
115
|
}
|
|
@@ -241,6 +140,7 @@ export function loadConfig(
|
|
|
241
140
|
}
|
|
242
141
|
|
|
243
142
|
const templateConfig: SandboxConfig = JSON.parse(readFileSync(templatePath, 'utf-8'));
|
|
143
|
+
templateConfig.filesystem.denyWrite.push(globalPath);
|
|
244
144
|
const globalOverrides = readConfigFile(globalPath);
|
|
245
145
|
const baseConfig = deepMerge(templateConfig, globalOverrides);
|
|
246
146
|
|
|
@@ -248,12 +148,24 @@ export function loadConfig(
|
|
|
248
148
|
}
|
|
249
149
|
|
|
250
150
|
export function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
|
|
251
|
-
const
|
|
151
|
+
const next: Record<string, unknown> = { ...readConfigFile(configPath) };
|
|
152
|
+
for (const [key, value] of Object.entries(normalizeConfig(update))) {
|
|
153
|
+
if (!isRecord(value)) {
|
|
154
|
+
next[key] = value;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
252
157
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
158
|
+
const previous = next[key];
|
|
159
|
+
const section: Record<string, unknown> = isRecord(previous) ? { ...previous } : {};
|
|
160
|
+
for (const [field, fieldValue] of Object.entries(value)) {
|
|
161
|
+
if (fieldValue === undefined) continue;
|
|
162
|
+
const existing = section[field];
|
|
163
|
+
section[field] = Array.isArray(fieldValue)
|
|
164
|
+
? mergeArray(Array.isArray(existing) ? existing : [], fieldValue)
|
|
165
|
+
: fieldValue;
|
|
166
|
+
}
|
|
167
|
+
next[key] = section;
|
|
168
|
+
}
|
|
257
169
|
|
|
258
170
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
259
171
|
writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
|
|
@@ -298,18 +210,6 @@ export function landstripBinaryPath(): string {
|
|
|
298
210
|
}
|
|
299
211
|
}
|
|
300
212
|
|
|
301
|
-
export function extractDomainsFromCommand(command: string): string[] {
|
|
302
|
-
const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
|
|
303
|
-
const domains = new Set<string>();
|
|
304
|
-
let match: RegExpExecArray | null;
|
|
305
|
-
|
|
306
|
-
while ((match = urlRegex.exec(command)) !== null) {
|
|
307
|
-
if (match[1]) domains.add(match[1]);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return [...domains];
|
|
311
|
-
}
|
|
312
|
-
|
|
313
213
|
// Permission requests reach the plugin in slightly different shapes across the
|
|
314
214
|
// server hook and the TUI event bus, so the field-fallback parsing below is the
|
|
315
215
|
// single source of truth both entrypoints share.
|
package/tui.ts
CHANGED
|
@@ -797,8 +797,10 @@ const tui: TuiPlugin.Definition = {
|
|
|
797
797
|
row('Denied Domains', values(config.network.deniedDomains)),
|
|
798
798
|
row('Allowed Reads', values(config.filesystem.allowRead)),
|
|
799
799
|
row('Denied Reads', values(config.filesystem.denyRead)),
|
|
800
|
+
row('Always Denied Reads', values(config.filesystem.denyReadAlways)),
|
|
800
801
|
row('Allowed Writes', values(config.filesystem.allowWrite)),
|
|
801
802
|
row('Denied Writes', values(config.filesystem.denyWrite)),
|
|
803
|
+
row('Always Denied Writes', values(config.filesystem.denyWriteAlways)),
|
|
802
804
|
row(
|
|
803
805
|
'Configuration Scope',
|
|
804
806
|
enabledManagedByOptions
|