opencode-landstrip 0.19.4 → 0.19.7
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 -0
- package/index.ts +75 -215
- package/package.json +3 -3
- package/sandbox.json +3 -1
- package/shared.ts +49 -166
- package/tui.ts +2 -0
package/README.md
CHANGED
|
@@ -30,6 +30,13 @@ Arrays combine; later scalar values win. Defaults allow project writes, deny
|
|
|
30
30
|
sensitive-file writes, and block network access. Enabled sandboxing fails closed
|
|
31
31
|
on unusable binaries/platforms; `enabled: false` explicitly permits unsandboxed Bash.
|
|
32
32
|
|
|
33
|
+
- `filesystem.denyReadAlways` cannot be overridden by equal/nested read grants
|
|
34
|
+
and blocks promptable reads on those paths. Non-empty `denyReadAlways` is
|
|
35
|
+
unsupported on Windows.
|
|
36
|
+
- `filesystem.denyWriteAlways` cannot be overridden by equal/nested write grants
|
|
37
|
+
and blocks promptable writes on those paths. Non-empty `denyWriteAlways` is
|
|
38
|
+
unsupported on Windows.
|
|
39
|
+
|
|
33
40
|
See [landstrip(1)](../landstrip/man/man1/landstrip.1) for native CLI/policy semantics.
|
|
34
41
|
Runtime seccomp traps/query approval are Linux-only. Native macOS `--trap` reports
|
|
35
42
|
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.7",
|
|
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.7",
|
|
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,23 @@
|
|
|
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
|
+
parseSandboxConfig,
|
|
10
|
+
} from '@landstrip/landstrip-api/shared';
|
|
11
|
+
import type { SandboxConfig, SandboxConfigOverrides } from '@landstrip/landstrip-api/shared';
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
LandstripControlResponse,
|
|
15
|
+
LandstripFilesystemPolicy,
|
|
16
|
+
LandstripNetworkPolicy,
|
|
17
|
+
LandstripPolicy,
|
|
18
|
+
LandstripTrap,
|
|
19
|
+
} from '@landstrip/landstrip-api';
|
|
8
20
|
export {
|
|
21
|
+
buildLandstripPolicy,
|
|
9
22
|
allowsAllDomains,
|
|
10
23
|
canonicalizeGlobPattern,
|
|
11
24
|
canonicalizePath,
|
|
@@ -22,8 +35,39 @@ export {
|
|
|
22
35
|
normalizePathSeparators,
|
|
23
36
|
parseLandstripTraps,
|
|
24
37
|
pathUnderDirectory,
|
|
38
|
+
resolveFilesystemPatterns,
|
|
39
|
+
resolveFilesystemPolicy,
|
|
25
40
|
sessionAllows,
|
|
26
41
|
sessionScopeFor,
|
|
42
|
+
serializeLandstripPolicy,
|
|
43
|
+
deepMergeSandboxConfig,
|
|
44
|
+
mergeArray,
|
|
45
|
+
parseSandboxConfig,
|
|
46
|
+
matchesPathPattern,
|
|
47
|
+
matchPathSpecificity,
|
|
48
|
+
isPathReadAllowed,
|
|
49
|
+
isPathWriteAllowed,
|
|
50
|
+
shouldPromptForWrite,
|
|
51
|
+
evaluateReadAccess,
|
|
52
|
+
evaluateWriteAccess,
|
|
53
|
+
isDomainAllowed,
|
|
54
|
+
evaluateDomainAccess,
|
|
55
|
+
extractDomainsFromCommand,
|
|
56
|
+
extractCandidatePaths,
|
|
57
|
+
extractNativeDeniedPath,
|
|
58
|
+
extractDeniedPath,
|
|
59
|
+
type PathAccessDecision,
|
|
60
|
+
type DomainAccessDecision,
|
|
61
|
+
type EvaluateReadOptions,
|
|
62
|
+
type EvaluateWriteOptions,
|
|
63
|
+
type EvaluateDomainOptions,
|
|
64
|
+
type SandboxConfig,
|
|
65
|
+
type SandboxConfigFile,
|
|
66
|
+
type SandboxConfigOverrides,
|
|
67
|
+
type SandboxFilesystemConfig,
|
|
68
|
+
type SandboxNetworkConfig,
|
|
69
|
+
type SandboxShellConfig,
|
|
70
|
+
type SandboxWindowsConfig,
|
|
27
71
|
} from '@landstrip/landstrip-api/shared';
|
|
28
72
|
|
|
29
73
|
import { createHash } from 'node:crypto';
|
|
@@ -32,34 +76,6 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
32
76
|
import { fileURLToPath } from 'node:url';
|
|
33
77
|
import { dirname, join } from 'node:path';
|
|
34
78
|
|
|
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
|
-
}
|
|
62
|
-
|
|
63
79
|
const packageDir = dirname(fileURLToPath(import.meta.url));
|
|
64
80
|
|
|
65
81
|
const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
@@ -72,105 +88,8 @@ const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
|
72
88
|
'@landstrip/landstrip-win32-arm64',
|
|
73
89
|
]);
|
|
74
90
|
|
|
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
91
|
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;
|
|
92
|
+
return parseSandboxConfig(value);
|
|
174
93
|
}
|
|
175
94
|
|
|
176
95
|
export function normalizeOptions(options: unknown): SandboxConfigOverrides {
|
|
@@ -180,32 +99,8 @@ export function normalizeOptions(options: unknown): SandboxConfigOverrides {
|
|
|
180
99
|
return normalizeConfig(options.config);
|
|
181
100
|
}
|
|
182
101
|
|
|
183
|
-
function mergeArray(base: string[], override?: string[]): string[] {
|
|
184
|
-
if (!override) return base;
|
|
185
|
-
return [...new Set([...base, ...override])];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
102
|
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
|
-
};
|
|
103
|
+
return deepMergeSandboxConfig(base, overrides);
|
|
209
104
|
}
|
|
210
105
|
|
|
211
106
|
export function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
|
|
@@ -298,18 +193,6 @@ export function landstripBinaryPath(): string {
|
|
|
298
193
|
}
|
|
299
194
|
}
|
|
300
195
|
|
|
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
196
|
// Permission requests reach the plugin in slightly different shapes across the
|
|
314
197
|
// server hook and the TUI event bus, so the field-fallback parsing below is the
|
|
315
198
|
// 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
|