opencode-landstrip 0.19.6 → 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 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 { startFilterProxy } from '@landstrip/landstrip-api/proxy';
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
- domainMatchesAny,
26
+ evaluateDomainAccess,
27
+ evaluateReadAccess,
28
+ evaluateWriteAccess,
25
29
  extractDomainsFromCommand,
30
+ extractDeniedPath,
26
31
  formatLandstripTraps,
27
32
  getConfigPaths,
28
- globToRegExp,
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
- // Reads are interactive, so the read tool never hard-denies: a path covered by
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 access = writeAccess(
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 (access === 'deny') {
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 (access === 'allow') {
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
- if (config.network.allowNetwork) {
282
- return { status: 'allow', kind: 'domain', resource: domain, message: '' };
283
- }
154
+ const decision = evaluateDomainAccess(domain, config.network);
284
155
 
285
- if (domainMatchesAny(domain, config.network.deniedDomains)) {
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 (isDomainAllowed(domain, config)) {
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
- JSON.stringify(buildLandstripPolicy(config, baseDirectory, proxyPort), null, 2) + '\n',
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
- const credentials = proxyToken ? `landstrip:${proxyToken}@` : '';
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
- ? isReadAllowed(path, effectiveAllowRead, denyRead, baseDirectory)
439
- : writeAccess(path, effectiveAllowWrite, denyWrite, baseDirectory) === 'allow';
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 proxyToken = allowNetwork ? null : randomBytes(32).toString('base64url');
830
- const proxyAuthorization =
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
- ...(proxyAuthorization === undefined ? {} : { authorization: proxyAuthorization }),
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((path): path is string => typeof path === 'string');
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
- : extractBlockedPath(outputText, directory, state.originalCommand);
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.6",
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.6",
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.68.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 { isRecord } from '@landstrip/landstrip-api/shared';
6
-
7
- export type { LandstripControlResponse, LandstripTrap } from '@landstrip/landstrip-api';
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
- if (!isRecord(value)) throw new Error('sandbox config must be an object');
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
- const network = overrides.network;
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