opencode-landstrip 0.20.14 → 0.20.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.ts +36 -53
  2. package/package.json +2 -2
  3. package/shared.ts +9 -172
  4. package/tui.ts +39 -20
package/index.ts CHANGED
@@ -15,30 +15,33 @@ import {
15
15
  startFilterProxy,
16
16
  } from '@landstrip/landstrip-api/proxy';
17
17
 
18
+ import type { LandstripTrap } from '@landstrip/landstrip-api';
18
19
  import {
19
- type LandstripTrap,
20
20
  type SandboxConfig,
21
21
  allowsAllDomains,
22
+ buildLandstripPolicy,
22
23
  canonicalizePath,
23
24
  controlResponseLine,
24
25
  evaluateDomainAccess,
25
26
  evaluateReadAccess,
26
27
  evaluateWriteAccess,
27
- extractDomainsFromCommand,
28
28
  extractDeniedPath,
29
+ extractDomainsFromCommand,
29
30
  formatLandstripTraps,
30
- getConfigPaths,
31
31
  isDomainAllowed,
32
- isRecord,
33
32
  isQueryTrap,
34
- buildLandstripPolicy,
33
+ isRecord,
34
+ parseLandstripTraps,
35
+ parseTrapLine,
36
+ writeLandstripPolicyFile,
37
+ } from '@landstrip/landstrip-api/shared';
38
+
39
+ import {
40
+ getConfigPaths,
35
41
  landstripBinaryPath,
36
42
  loadConfig,
37
43
  normalizeOptions,
38
- parseLandstripTraps,
39
- parseTrapLine,
40
44
  readDiscoveryPort,
41
- writeLandstripPolicyFile,
42
45
  trapSessionHelloLine,
43
46
  } from './shared.js';
44
47
 
@@ -117,21 +120,12 @@ function evaluateWritePermission(
117
120
  allowWriteOverrides: effectiveAllowWrite,
118
121
  });
119
122
 
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
123
  if (decision === 'deny') {
130
124
  return {
131
125
  status: 'deny',
132
126
  kind: 'write',
133
127
  resource: filePath,
134
- message: `Sandbox: write access denied for "${filePath}" (denyWrite overrides allowWrite).`,
128
+ message: `Sandbox: write access denied for "${filePath}" (denyWriteAlways).`,
135
129
  };
136
130
  }
137
131
 
@@ -143,7 +137,7 @@ function evaluateWritePermission(
143
137
  status: 'ask',
144
138
  kind: 'write',
145
139
  resource: filePath,
146
- message: `Sandbox: write access requires approval for "${filePath}" (not in filesystem.allowWrite).`,
140
+ message: `Sandbox: write access requires approval for "${filePath}".`,
147
141
  };
148
142
  }
149
143
 
@@ -228,7 +222,7 @@ function proxyEnv(
228
222
  proxyToken?: string | null,
229
223
  ): Record<string, string> | undefined {
230
224
  if (port === null) return undefined;
231
- return createProxyEnvironment(port, proxyToken) as Record<string, string>;
225
+ return createProxyEnvironment(port, { token: proxyToken }) as Record<string, string>;
232
226
  }
233
227
 
234
228
  function shellQuote(value: string): string {
@@ -414,17 +408,8 @@ function extractOriginalCommand(wrappedCommand: string): string | null {
414
408
  const pIdx = args.indexOf('-p');
415
409
  const flagIdx = args.findIndex((arg, i) => i > pIdx && (arg === '-lc' || arg === '-c'));
416
410
  if (flagIdx === -1) return null;
417
- // Old query-response wrappers appended `|| <fallback>`; stop there so
418
- // recovering an expired wrapper never folds a fallback into the command.
419
- const end = args.indexOf('||', flagIdx + 1);
420
- return (end === -1 ? args.slice(flagIdx + 1) : args.slice(flagIdx + 1, end)).join(' ');
421
- }
422
-
423
- function getToolPath(args: Record<string, unknown>): string | undefined {
424
- const filePath = args.filePath ?? args.path;
425
- return typeof filePath === 'string' ? filePath : undefined;
411
+ return args.slice(flagIdx + 1).join(' ');
426
412
  }
427
-
428
413
  function extractPatchPaths(patchText: string): string[] {
429
414
  const paths: string[] = [];
430
415
 
@@ -453,13 +438,15 @@ function evaluateToolPermissions(
453
438
  if (tool === 'read') {
454
439
  const paths = Array.isArray(args.paths)
455
440
  ? args.paths.filter((path): path is string => typeof path === 'string')
456
- : [getToolPath(args)].filter((path): path is string => path !== undefined);
441
+ : typeof args.path === 'string'
442
+ ? [args.path]
443
+ : [];
457
444
  return paths.map((path) =>
458
445
  evaluateReadPermission(path, config, baseDirectory, effectiveAllowRead),
459
446
  );
460
447
  }
461
448
 
462
- if (tool === 'glob' || tool === 'grep' || tool === 'list') {
449
+ if (tool === 'glob' || tool === 'grep') {
463
450
  const searchPath = typeof args.path === 'string' ? args.path : '.';
464
451
  return [evaluateReadPermission(searchPath, config, baseDirectory, effectiveAllowRead)];
465
452
  }
@@ -467,22 +454,28 @@ function evaluateToolPermissions(
467
454
  if (tool === 'write' || tool === 'edit') {
468
455
  const paths = Array.isArray(args.paths)
469
456
  ? args.paths.filter((path): path is string => typeof path === 'string')
470
- : [getToolPath(args)].filter((path): path is string => path !== undefined);
457
+ : typeof args.path === 'string'
458
+ ? [args.path]
459
+ : [];
471
460
  return paths.map((path) =>
472
461
  evaluateWritePermission(path, config, baseDirectory, effectiveAllowWrite),
473
462
  );
474
463
  }
475
464
 
476
- if ((tool === 'patch' || tool === 'apply_patch') && typeof args.patchText === 'string') {
465
+ if (tool === 'patch' && typeof args.patchText === 'string') {
477
466
  return extractPatchPaths(args.patchText).map((path) =>
478
467
  evaluateWritePermission(path, config, baseDirectory, effectiveAllowWrite),
479
468
  );
480
469
  }
481
470
 
482
- if (tool === 'bash' && typeof args.command === 'string') {
483
- return evaluateCommandDomains(args.command, config);
471
+ if (tool === 'shell') {
472
+ const commands = Array.isArray(args.commands)
473
+ ? args.commands.filter((command): command is string => typeof command === 'string')
474
+ : typeof args.command === 'string'
475
+ ? [args.command]
476
+ : [];
477
+ return commands.flatMap((command) => evaluateCommandDomains(command, config));
484
478
  }
485
-
486
479
  return [];
487
480
  }
488
481
 
@@ -672,7 +665,7 @@ const plugin: Plugin.Plugin = {
672
665
  const proxy = allowNetwork
673
666
  ? null
674
667
  : await startFilterProxy({
675
- isDomainAllowed: (domain) => isDomainAllowed(domain, config),
668
+ isDomainAllowed: (domain) => isDomainAllowed(domain, config.network),
676
669
  ...(proxyCredentials ? { authorization: proxyCredentials.authorization } : {}),
677
670
  });
678
671
  const proxyPort = proxy ? proxy.port : null;
@@ -745,24 +738,14 @@ const plugin: Plugin.Plugin = {
745
738
  if (!config) return;
746
739
 
747
740
  const action = evaluation.action;
748
- const patterns = [...(evaluation.resources ?? [])];
741
+ const resources = [...(evaluation.resources ?? [])];
749
742
  const effectiveAllowRead = config.filesystem.allowRead;
750
743
  const effectiveAllowWrite = config.filesystem.allowWrite;
751
744
  const args: Record<string, unknown> = { ...evaluation.metadata };
752
- if (action === 'read' || action === 'external_directory') args.paths = patterns;
753
- if (action === 'write' || action === 'edit') {
754
- args.paths =
755
- patterns.length > 0
756
- ? patterns
757
- : [args.filepath ?? args.path].filter(
758
- (path): path is string => typeof path === 'string',
759
- );
760
- }
761
- if ((action === 'shell' || action === 'bash') && typeof args.command !== 'string') {
762
- args.command = patterns[0];
763
- }
764
- const toolName =
765
- action === 'shell' ? 'bash' : action === 'external_directory' ? 'read' : action;
745
+ if (action === 'read' || action === 'external_directory') args.paths = resources;
746
+ if (action === 'edit') args.paths = resources;
747
+ if (action === 'shell') args.commands = resources;
748
+ const toolName = action === 'external_directory' ? 'read' : action;
766
749
  const decisions = evaluateToolPermissions(
767
750
  toolName,
768
751
  args,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.20.14",
3
+ "version": "0.20.15",
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.20.14",
51
+ "@landstrip/landstrip-api": "^0.20.15",
52
52
  "@opentui/core": ">=0.5.12",
53
53
  "@opentui/solid": ">=0.5.12",
54
54
  "solid-js": "1.9.12"
package/shared.ts CHANGED
@@ -4,79 +4,11 @@
4
4
  import { binaryPath } from '@landstrip/landstrip-api';
5
5
  import {
6
6
  deepMergeSandboxConfig,
7
- extractDomainsFromCommand,
8
7
  isRecord,
9
8
  mergeArray,
10
9
  parseSandboxConfig,
11
10
  } from '@landstrip/landstrip-api/shared';
12
11
  import type { SandboxConfig, SandboxConfigOverrides } from '@landstrip/landstrip-api/shared';
13
-
14
- export type {
15
- LandstripControlResponse,
16
- LandstripDarwinPolicy,
17
- LandstripFilesystemPolicy,
18
- LandstripNetworkPolicy,
19
- LandstripPathSelector,
20
- LandstripPolicy,
21
- LandstripTrap,
22
- } from '@landstrip/landstrip-api';
23
- export {
24
- buildLandstripPolicy,
25
- allowsAllDomains,
26
- canonicalizeGlobPattern,
27
- canonicalizePath,
28
- controlResponseLine,
29
- decodeLandstripTrap,
30
- domainMatchesAny,
31
- domainMatchesPattern,
32
- expandHomePath,
33
- expandPath,
34
- globToRegExp,
35
- formatLandstripTrap,
36
- formatLandstripTraps,
37
- isRecord,
38
- isQueryTrap,
39
- normalizePathSeparators,
40
- parseLandstripTraps,
41
- parseTrapLine,
42
- pathUnderDirectory,
43
- resolveFilesystemPatterns,
44
- resolveFilesystemPolicy,
45
- sessionAllows,
46
- sessionScopeFor,
47
- serializeLandstripPolicy,
48
- writeLandstripPolicyFile,
49
- deepMergeSandboxConfig,
50
- mergeArray,
51
- parseSandboxConfig,
52
- matchesPathPattern,
53
- matchPathSpecificity,
54
- isPathReadAllowed,
55
- isPathWriteAllowed,
56
- shouldPromptForWrite,
57
- evaluateReadAccess,
58
- evaluateWriteAccess,
59
- isDomainAllowed,
60
- evaluateDomainAccess,
61
- extractDomainsFromCommand,
62
- extractCandidatePaths,
63
- extractNativeDeniedPath,
64
- extractDeniedPath,
65
- type PathAccessDecision,
66
- type DomainAccessDecision,
67
- type EvaluateReadOptions,
68
- type EvaluateWriteOptions,
69
- type EvaluateDomainOptions,
70
- type SandboxConfig,
71
- type SandboxConfigFile,
72
- type SandboxConfigOverrides,
73
- type SandboxDarwinConfig,
74
- type SandboxFilesystemConfig,
75
- type SandboxNetworkConfig,
76
- type SandboxShellConfig,
77
- type SandboxWindowsConfig,
78
- } from '@landstrip/landstrip-api/shared';
79
-
80
12
  import { createHash } from 'node:crypto';
81
13
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
82
14
  import { homedir, tmpdir } from 'node:os';
@@ -85,21 +17,12 @@ import { dirname, isAbsolute, join } from 'node:path';
85
17
 
86
18
  const packageDir = dirname(fileURLToPath(import.meta.url));
87
19
 
88
- export function normalizeConfig(value: unknown): SandboxConfigOverrides {
89
- return parseSandboxConfig(value);
90
- }
91
-
92
20
  export function normalizeOptions(options: unknown): SandboxConfigOverrides {
93
21
  if (!isRecord(options)) return {};
94
- if (options.config === undefined) return normalizeConfig(options);
22
+ if (options.config === undefined) return parseSandboxConfig(options);
95
23
  if (!isRecord(options.config)) throw new Error('config must be an object');
96
- return normalizeConfig(options.config);
24
+ return parseSandboxConfig(options.config);
97
25
  }
98
-
99
- export function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
100
- return deepMergeSandboxConfig(base, overrides);
101
- }
102
-
103
26
  export function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
104
27
  const configHome = process.env.XDG_CONFIG_HOME;
105
28
  const configDirectory =
@@ -114,7 +37,7 @@ export function readConfigFile(configPath: string): SandboxConfigOverrides {
114
37
  if (!existsSync(configPath)) return {};
115
38
 
116
39
  try {
117
- return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
40
+ return parseSandboxConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
118
41
  } catch (error) {
119
42
  throw new Error(
120
43
  `Could not load ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
@@ -138,14 +61,17 @@ export function loadConfig(
138
61
  const templateConfig: SandboxConfig = JSON.parse(readFileSync(templatePath, 'utf-8'));
139
62
  templateConfig.filesystem.denyWrite.push(globalPath);
140
63
  const globalOverrides = readConfigFile(globalPath);
141
- const baseConfig = deepMerge(templateConfig, globalOverrides);
64
+ const baseConfig = deepMergeSandboxConfig(templateConfig, globalOverrides);
142
65
 
143
- return deepMerge(deepMerge(baseConfig, readConfigFile(projectPath)), optionOverrides);
66
+ return deepMergeSandboxConfig(
67
+ deepMergeSandboxConfig(baseConfig, readConfigFile(projectPath)),
68
+ optionOverrides,
69
+ );
144
70
  }
145
71
 
146
72
  export function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
147
73
  const next: Record<string, unknown> = { ...readConfigFile(configPath) };
148
- for (const [key, value] of Object.entries(normalizeConfig(update))) {
74
+ for (const [key, value] of Object.entries(parseSandboxConfig(update))) {
149
75
  if (!isRecord(value)) {
150
76
  next[key] = value;
151
77
  continue;
@@ -183,95 +109,6 @@ export function landstripBinaryPath(): string {
183
109
  }
184
110
  }
185
111
 
186
- // Permission requests reach the plugin in slightly different shapes across the
187
- // server hook and the TUI event bus, so the field-fallback parsing below is the
188
- // single source of truth both entrypoints share.
189
- export function permissionType(permission: Record<string, unknown>, fallback = ''): string {
190
- if (typeof permission.permission === 'string') return permission.permission;
191
- if (typeof permission.action === 'string') return permission.action;
192
- if (typeof permission.type === 'string') return permission.type;
193
- return fallback;
194
- }
195
-
196
- export function permissionPattern(permission: Record<string, unknown>): string | undefined {
197
- const patterns = permission.patterns;
198
- if (Array.isArray(patterns))
199
- return patterns.find((item): item is string => typeof item === 'string');
200
-
201
- const pattern = permission.pattern;
202
- if (typeof pattern === 'string') return pattern;
203
- if (Array.isArray(pattern))
204
- return pattern.find((item): item is string => typeof item === 'string');
205
-
206
- return undefined;
207
- }
208
-
209
- /**
210
- * Every string pattern a permission carries, in declaration order, read from
211
- * `patterns`, `pattern`, or `resources`. The plural complement to
212
- * {@link permissionPattern} for callers that must inspect all patterns.
213
- */
214
- export function permissionPatterns(permission: Record<string, unknown>): string[] {
215
- const patterns = permission.patterns;
216
- if (Array.isArray(patterns))
217
- return patterns.filter((item): item is string => typeof item === 'string');
218
-
219
- const pattern = permission.pattern;
220
- if (typeof pattern === 'string') return [pattern];
221
- if (Array.isArray(pattern))
222
- return pattern.filter((item): item is string => typeof item === 'string');
223
-
224
- const resources = permission.resources;
225
- if (Array.isArray(resources))
226
- return resources.filter((item): item is string => typeof item === 'string');
227
-
228
- return [];
229
- }
230
-
231
- // The concrete resource a permission concerns (a path or a domain), used to show
232
- // the user exactly what they are approving and to persist the right allowlist.
233
- export function permissionResource(permission: Record<string, unknown>): string | undefined {
234
- const metadata = isRecord(permission.metadata) ? permission.metadata : {};
235
- const type = permissionType(permission);
236
- const pattern = permissionPattern(permission);
237
-
238
- if (type === 'bash') {
239
- const command = typeof metadata.command === 'string' ? metadata.command : pattern;
240
- const domains = typeof command === 'string' ? extractDomainsFromCommand(command) : [];
241
- return domains.length > 0 ? domains.join(', ') : (command ?? pattern);
242
- }
243
-
244
- if (typeof metadata.filepath === 'string') return metadata.filepath;
245
- if (typeof metadata.path === 'string') return metadata.path;
246
- return pattern;
247
- }
248
-
249
- export function updateForPermission(
250
- permission: Record<string, unknown>,
251
- ): SandboxConfigOverrides | null {
252
- const metadata = isRecord(permission.metadata) ? permission.metadata : {};
253
- const type = permissionType(permission);
254
- const pattern = permissionPattern(permission);
255
-
256
- if (type === 'bash') {
257
- const command = typeof metadata.command === 'string' ? metadata.command : pattern;
258
- const domains = typeof command === 'string' ? extractDomainsFromCommand(command) : [];
259
- return domains.length > 0 ? { network: { allowedDomains: domains } } : null;
260
- }
261
-
262
- if (type === 'read' || type === 'glob' || type === 'grep' || type === 'list') {
263
- const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
264
- return filePath ? { filesystem: { allowRead: [filePath] } } : null;
265
- }
266
-
267
- if (type === 'edit' || type === 'write' || type === 'apply_patch') {
268
- const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
269
- return filePath ? { filesystem: { allowWrite: [filePath] } } : null;
270
- }
271
-
272
- return null;
273
- }
274
-
275
112
  export interface TrapSessionHello {
276
113
  kind: 'opencode-landstrip-session';
277
114
  sessionID: string;
package/tui.ts CHANGED
@@ -14,23 +14,28 @@ import { createSignal, onMount } from 'solid-js';
14
14
 
15
15
  import {
16
16
  controlResponseLine,
17
+ evaluateReadAccess,
18
+ evaluateWriteAccess,
19
+ isPathReadAllowed,
20
+ isPathWriteAllowed,
21
+ isQueryTrap,
22
+ parseLandstripTraps,
23
+ sessionScopeFor,
24
+ } from '@landstrip/landstrip-api/shared';
25
+
26
+ import {
17
27
  decodeTrapSessionHello,
18
28
  getConfigPaths,
19
- isQueryTrap,
20
29
  loadConfig,
21
30
  normalizeOptions,
22
31
  nextSandboxPermissionIndex,
23
- parseLandstripTraps,
24
32
  removeDiscoveryFile,
25
- sessionAllows,
26
33
  sessionAllowancesFor,
27
34
  sandboxConfigTarget,
28
- sessionScopeFor,
29
35
  rootSessionIDFor,
30
36
  setSandboxConfigEnabled,
31
37
  shouldRenderSandboxPermission,
32
38
  type SessionAllowances,
33
- updateForPermission,
34
39
  writeConfigFile,
35
40
  writeDiscoveryPort,
36
41
  } from './shared.js';
@@ -463,21 +468,29 @@ const tui: TuiPlugin.Definition = {
463
468
  const action = choice === 'deny' ? 'deny' : 'allow';
464
469
  const verb = entry.operation === 'read' ? 'Read' : 'Write';
465
470
  const directory = entry.directory;
466
- const scope = sessionScopeFor(entry.path, directory);
471
+ const config = loadConfig(directory, optionOverrides);
472
+ let scope = sessionScopeFor(entry.path, directory);
473
+ if (
474
+ entry.operation === 'read'
475
+ ? !isPathReadAllowed(entry.path, [scope], config.filesystem.denyRead, directory, {
476
+ requireAllowMatch: true,
477
+ })
478
+ : !isPathWriteAllowed(entry.path, [scope], config.filesystem.denyWrite, directory)
479
+ ) {
480
+ scope = entry.path;
481
+ }
467
482
  const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
468
483
  const sessionPaths =
469
484
  entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
470
-
471
485
  let responseAction: 'allow' | 'deny' = action;
472
486
  try {
473
487
  if (action === 'allow') {
474
488
  if (choice === 'project' || choice === 'global') {
475
489
  const { globalPath, projectPath } = getConfigPaths(directory);
476
- const update = updateForPermission({
477
- permission: entry.operation,
478
- metadata: { filepath: scope },
490
+ writeConfigFile(choice === 'project' ? projectPath : globalPath, {
491
+ filesystem:
492
+ entry.operation === 'read' ? { allowRead: [scope] } : { allowWrite: [scope] },
479
493
  });
480
- if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
481
494
  }
482
495
  if (choice !== 'once') sessionPaths.add(scope);
483
496
  }
@@ -581,23 +594,29 @@ const tui: TuiPlugin.Definition = {
581
594
  if (seen.has(trap.query_id)) continue;
582
595
  seen.add(trap.query_id);
583
596
 
584
- if (!loadConfig(sessionDirectory, optionOverrides).enabled) {
597
+ const config = loadConfig(sessionDirectory, optionOverrides);
598
+ if (!config.enabled) {
585
599
  respondQuery(socket, trap.query_id, 'allow');
586
600
  continue;
587
601
  }
588
602
 
589
603
  if (trap.kind === 'filesystem') {
590
- if (trap.operation === 'write' && trap.reason === 'deny_match') {
591
- respondQuery(socket, trap.query_id, 'deny');
592
- continue;
593
- }
594
- const sessionPaths =
595
- trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
596
- if (sessionAllows(sessionPaths, trap.path)) {
604
+ const sessionAllowed =
605
+ trap.operation === 'read'
606
+ ? evaluateReadAccess(
607
+ trap.path,
608
+ { ...config.filesystem, allowRead: [...allowances.readPaths] },
609
+ { baseDirectory: sessionDirectory, requireAllowMatch: true },
610
+ ) === 'allow'
611
+ : evaluateWriteAccess(
612
+ trap.path,
613
+ { ...config.filesystem, allowWrite: [...allowances.writePaths] },
614
+ { baseDirectory: sessionDirectory },
615
+ ) === 'allow';
616
+ if (sessionAllowed) {
597
617
  respondQuery(socket, trap.query_id, 'allow');
598
618
  continue;
599
619
  }
600
-
601
620
  const entry: FsQueryEntry = {
602
621
  kind: 'fs-query',
603
622
  id: `landstrip-${trap.operation}:${socketId}:${trap.query_id}`,