opencode-landstrip 0.17.19 → 0.17.21

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 (3) hide show
  1. package/index.ts +136 -130
  2. package/package.json +4 -4
  3. package/tui.ts +2 -1
package/index.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type SandboxConfig,
16
16
  type SandboxFilesystemConfig,
17
17
  controlResponseLine,
18
+ decodeLandstripTrap,
18
19
  extractDomainsFromCommand,
19
20
  formatLandstripTraps,
20
21
  getConfigPaths,
@@ -29,16 +30,12 @@ import {
29
30
  sessionScopeFor,
30
31
  } from './shared.js';
31
32
 
32
- interface LandstripPolicy {
33
- network: {
34
- allowNetwork: boolean;
35
- allowLocalBinding: boolean;
36
- allowAllUnixSockets: boolean;
37
- allowUnixSockets: string[];
33
+ type LandstripPolicy = {
34
+ network: Omit<SandboxConfig['network'], 'allowedDomains' | 'deniedDomains'> & {
38
35
  httpProxyPort?: number;
39
36
  };
40
37
  filesystem: SandboxFilesystemConfig;
41
- }
38
+ };
42
39
 
43
40
  interface BashSandboxState {
44
41
  originalCommand: string;
@@ -219,8 +216,23 @@ function allowsAllDomains(allowedDomains: string[]): boolean {
219
216
  return allowedDomains.includes('*');
220
217
  }
221
218
 
222
- function normalizeBlockedPath(path: string, baseDirectory: string): string {
223
- return canonicalizePath(isAbsolute(path) ? path : join(baseDirectory, path), baseDirectory);
219
+ function isDomainAllowed(domain: string, config: SandboxConfig): boolean {
220
+ return (
221
+ config.network.allowNetwork ||
222
+ (!domainMatchesAny(domain, config.network.deniedDomains) &&
223
+ domainMatchesAny(domain, config.network.allowedDomains))
224
+ );
225
+ }
226
+
227
+ function isFilesystemAllowed(
228
+ path: string,
229
+ allowPatterns: string[],
230
+ denyPatterns: string[],
231
+ baseDirectory: string,
232
+ ): boolean {
233
+ const allowDepth = matchDepth(path, allowPatterns, baseDirectory);
234
+ const denyDepth = matchDepth(path, denyPatterns, baseDirectory);
235
+ return allowDepth >= 0 && allowDepth >= denyDepth;
224
236
  }
225
237
 
226
238
  function extractCandidatePaths(command: string): string[] {
@@ -250,24 +262,24 @@ function extractBlockedPath(
250
262
  let match = output.match(
251
263
  /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
252
264
  );
253
- if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
265
+ if (match?.[1]) return canonicalizePath(match[1], baseDirectory);
254
266
 
255
267
  // ls/cat/cp: cannot open/access/stat '/path': Permission denied
256
268
  match = output.match(
257
269
  /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
258
270
  );
259
- if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
271
+ if (match?.[1]) return canonicalizePath(match[1], baseDirectory);
260
272
 
261
273
  // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
262
274
  match = output.match(
263
275
  /^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
264
276
  );
265
- if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
277
+ if (match?.[1]) return canonicalizePath(match[1], baseDirectory);
266
278
 
267
279
  // Landstrip structured trap format carrying a denied path
268
280
  const landstripTraps = parseLandstripTraps(output);
269
281
  for (const trap of landstripTraps) {
270
- if (trap.kind === 'filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
282
+ if (trap.kind === 'filesystem') return canonicalizePath(trap.path, baseDirectory);
271
283
  }
272
284
 
273
285
  if (
@@ -290,15 +302,15 @@ function evaluateReadPermission(
290
302
  effectiveAllowRead: string[],
291
303
  ): SandboxPermissionDecision {
292
304
  const filePath = canonicalizePath(path, baseDirectory);
293
- const allowDepth = matchDepth(filePath, effectiveAllowRead, baseDirectory);
294
- const denyDepth = matchDepth(filePath, config.filesystem.denyRead, baseDirectory);
295
305
 
296
306
  // Reads are interactive, so the read tool never hard-denies: a path covered by
297
307
  // allowRead at least as specifically as any denyRead is allowed silently;
298
308
  // everything else asks for approval (allow once/session/persist or reject)
299
309
  // rather than being blocked outright. denyRead still hard-applies to bash
300
310
  // through the landstrip binary policy, which has no way to prompt.
301
- if (allowDepth >= 0 && allowDepth >= denyDepth) {
311
+ if (
312
+ isFilesystemAllowed(filePath, effectiveAllowRead, config.filesystem.denyRead, baseDirectory)
313
+ ) {
302
314
  return { status: 'allow', kind: 'read', resource: filePath, message: '' };
303
315
  }
304
316
 
@@ -329,7 +341,9 @@ function evaluateWritePermission(
329
341
  };
330
342
  }
331
343
 
332
- if (allowDepth >= 0) {
344
+ if (
345
+ isFilesystemAllowed(filePath, effectiveAllowWrite, config.filesystem.denyWrite, baseDirectory)
346
+ ) {
333
347
  return { status: 'allow', kind: 'write', resource: filePath, message: '' };
334
348
  }
335
349
 
@@ -358,7 +372,7 @@ function evaluateDomainPermission(
358
372
  };
359
373
  }
360
374
 
361
- if (domainMatchesAny(domain, config.network.allowedDomains)) {
375
+ if (isDomainAllowed(domain, config)) {
362
376
  return { status: 'allow', kind: 'domain', resource: domain, message: '' };
363
377
  }
364
378
 
@@ -370,6 +384,16 @@ function evaluateDomainPermission(
370
384
  };
371
385
  }
372
386
 
387
+ function evaluateCommandDomains(
388
+ command: string,
389
+ config: SandboxConfig,
390
+ ): SandboxPermissionDecision[] {
391
+ if (config.network.allowNetwork) return [];
392
+ return extractDomainsFromCommand(command).map((domain) =>
393
+ evaluateDomainPermission(domain, config),
394
+ );
395
+ }
396
+
373
397
  function landstripVersion(): string | null {
374
398
  const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
375
399
  if (result.status !== 0) return null;
@@ -467,11 +491,6 @@ function writePolicyFile(
467
491
  function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () => Promise<void> }> {
468
492
  const sockets = new Set<Socket>();
469
493
 
470
- function domainAllowed(domain: string): boolean {
471
- if (domainMatchesAny(domain, config.network.deniedDomains)) return false;
472
- return domainMatchesAny(domain, config.network.allowedDomains);
473
- }
474
-
475
494
  async function handleConnect(client: Socket, target: string, rest: Buffer): Promise<void> {
476
495
  const endpoint = splitHostPort(target, 443);
477
496
  if (!endpoint || !Number.isFinite(endpoint.port)) {
@@ -479,7 +498,7 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
479
498
  return;
480
499
  }
481
500
 
482
- if (!domainAllowed(endpoint.host)) {
501
+ if (!isDomainAllowed(endpoint.host, config)) {
483
502
  denyProxyRequest(client);
484
503
  return;
485
504
  }
@@ -525,7 +544,7 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
525
544
  url = new URL(`http://${host}${rawTarget}`);
526
545
  }
527
546
 
528
- if (!domainAllowed(url.hostname)) {
547
+ if (!isDomainAllowed(url.hostname, config)) {
529
548
  denyProxyRequest(client);
530
549
  return;
531
550
  }
@@ -660,33 +679,25 @@ function startTrapServer(
660
679
  buffer = buffer.slice(nl + 1);
661
680
  nl = buffer.indexOf('\n');
662
681
  if (line.length === 0) continue;
663
- let obj: Record<string, unknown> | null = null;
682
+ let trap: LandstripTrap | null = null;
664
683
  try {
665
- const parsed: unknown = JSON.parse(line);
666
- if (typeof parsed === 'object' && parsed !== null) {
667
- obj = parsed as Record<string, unknown>;
668
- }
684
+ trap = decodeLandstripTrap(JSON.parse(line));
669
685
  } catch {
670
- obj = null;
686
+ trap = null;
671
687
  }
672
- if (obj && obj.state === 'query' && typeof obj.query_id === 'string') {
673
- const queryId = obj.query_id;
674
- if (
675
- (obj.operation === 'read' || obj.operation === 'write') &&
676
- typeof obj.path === 'string'
677
- ) {
678
- const path = canonicalizePath(obj.path, baseDirectory);
679
- const operation = obj.operation as 'read' | 'write';
680
- // Per landstrip policy: a deny rule overrides allow only when more
681
- // specific; a tie or more-specific allow carves the path back in.
682
- const denyReadDepth = matchDepth(path, denyRead, baseDirectory);
683
- const allowReadDepth = matchDepth(path, effectiveAllowRead, baseDirectory);
684
- const denyWriteDepth = matchDepth(path, denyWrite, baseDirectory);
685
- const allowWriteDepth = matchDepth(path, effectiveAllowWrite, baseDirectory);
688
+ if (
689
+ (trap?.kind === 'filesystem' || trap?.kind === 'network') &&
690
+ trap.state === 'query' &&
691
+ trap.query_id
692
+ ) {
693
+ const queryId = trap.query_id;
694
+ if (trap.kind === 'filesystem') {
695
+ const path = canonicalizePath(trap.path, baseDirectory);
696
+ const operation = trap.operation;
686
697
  const allowed =
687
698
  operation === 'read'
688
- ? !(denyReadDepth > allowReadDepth) && allowReadDepth >= 0
689
- : !(denyWriteDepth > allowWriteDepth) && allowWriteDepth >= 0;
699
+ ? isFilesystemAllowed(path, effectiveAllowRead, denyRead, baseDirectory)
700
+ : isFilesystemAllowed(path, effectiveAllowWrite, denyWrite, baseDirectory);
690
701
  if (allowed) {
691
702
  trapSocket.write(controlResponseLine(queryId, 'allow'));
692
703
  } else {
@@ -698,15 +709,15 @@ function startTrapServer(
698
709
  trapLines.push(line);
699
710
  }
700
711
  } else if (
701
- (obj.operation === 'connect' || obj.operation === 'bind') &&
702
- typeof obj.target === 'string'
712
+ trap.kind === 'network' &&
713
+ (trap.operation === 'connect' || trap.operation === 'bind')
703
714
  ) {
704
715
  // No policy field expresses "allow this address:port" (allowedDomains
705
716
  // is hostname-based and enforced by the HTTP(S) proxy, not the broker),
706
717
  // so — matching the filesystem branch above — auto-grant, remember it
707
718
  // for the rest of the session, and surface it afterward rather than
708
719
  // hanging or hard-denying an already-approved command.
709
- const target = obj.target;
720
+ const target = trap.target;
710
721
  if (!sessionAllowedTargets.has(target)) {
711
722
  sessionAllowedTargets.add(target);
712
723
  trapLines.push(line);
@@ -843,6 +854,45 @@ function extractPatchPaths(patchText: string): string[] {
843
854
  return paths;
844
855
  }
845
856
 
857
+ function evaluateToolPermissions(
858
+ tool: string,
859
+ args: Record<string, unknown>,
860
+ config: SandboxConfig,
861
+ baseDirectory: string,
862
+ effectiveAllowRead: string[],
863
+ effectiveAllowWrite: string[],
864
+ ): SandboxPermissionDecision[] {
865
+ if (tool === 'read') {
866
+ const paths = Array.isArray(args.paths)
867
+ ? args.paths.filter((path): path is string => typeof path === 'string')
868
+ : [getToolPath(args)].filter((path): path is string => path !== undefined);
869
+ return paths.map((path) =>
870
+ evaluateReadPermission(path, config, baseDirectory, effectiveAllowRead),
871
+ );
872
+ }
873
+
874
+ if (tool === 'glob' || tool === 'grep' || tool === 'list') {
875
+ return [evaluateReadPermission(getSearchPath(args), config, baseDirectory, effectiveAllowRead)];
876
+ }
877
+
878
+ if (tool === 'write' || tool === 'edit') {
879
+ const path = getToolPath(args);
880
+ return path ? [evaluateWritePermission(path, config, baseDirectory, effectiveAllowWrite)] : [];
881
+ }
882
+
883
+ if (tool === 'apply_patch' && typeof args.patchText === 'string') {
884
+ return extractPatchPaths(args.patchText).map((path) =>
885
+ evaluateWritePermission(path, config, baseDirectory, effectiveAllowWrite),
886
+ );
887
+ }
888
+
889
+ if (tool === 'bash' && typeof args.command === 'string') {
890
+ return evaluateCommandDomains(args.command, config);
891
+ }
892
+
893
+ return [];
894
+ }
895
+
846
896
  function errorWithConfigPaths(baseDirectory: string, message: string): Error {
847
897
  const { globalPath, projectPath } = getConfigPaths(baseDirectory);
848
898
  return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
@@ -857,12 +907,16 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
857
907
  const sessionAllowedWritePaths = new Set<string>();
858
908
  const sessionAllowedTargets = new Set<string>();
859
909
 
910
+ function mergeAllowances(configured: string[], session: Set<string>): string[] {
911
+ return [...configured, ...session];
912
+ }
913
+
860
914
  function getEffectiveAllowRead(config: SandboxConfig): string[] {
861
- return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
915
+ return mergeAllowances(config.filesystem.allowRead, sessionAllowedReadPaths);
862
916
  }
863
917
 
864
918
  function getEffectiveAllowWrite(config: SandboxConfig): string[] {
865
- return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
919
+ return mergeAllowances(config.filesystem.allowWrite, sessionAllowedWritePaths);
866
920
  }
867
921
  let enabledNotified = false;
868
922
  let configuredShell: string | undefined;
@@ -1139,11 +1193,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1139
1193
  };
1140
1194
 
1141
1195
  if (!allowNetwork) {
1142
- for (const domain of extractDomainsFromCommand(args.command as string)) {
1143
- const decision = evaluateDomainPermission(domain, effectiveConfig);
1196
+ for (const decision of evaluateCommandDomains(args.command as string, effectiveConfig)) {
1144
1197
  if (decision.status === 'allow') continue;
1145
1198
  if (decision.status === 'ask' && hasCallAllowance(callID, decision)) {
1146
- callAllowedDomains.push(domain);
1199
+ callAllowedDomains.push(decision.resource);
1147
1200
  continue;
1148
1201
  }
1149
1202
  throw errorWithConfigPaths(directory, decision.message);
@@ -1212,7 +1265,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1212
1265
 
1213
1266
  'permission.ask': async (input, output) => {
1214
1267
  const config = await activeConfig();
1215
- if (!config) return;
1268
+ if (!config) {
1269
+ output.status = 'allow';
1270
+ return;
1271
+ }
1216
1272
 
1217
1273
  const request = input as Record<string, unknown>;
1218
1274
  const permission = permissionType(request);
@@ -1226,41 +1282,22 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1226
1282
  : undefined;
1227
1283
  const patterns = permissionPatterns(request);
1228
1284
 
1229
- const decisions: SandboxPermissionDecision[] = [];
1230
1285
  const effectiveAllowRead = getEffectiveAllowRead(config);
1231
1286
  const effectiveAllowWrite = getEffectiveAllowWrite(config);
1232
-
1233
- if (permission === 'read') {
1234
- for (const pattern of patterns) {
1235
- decisions.push(evaluateReadPermission(pattern, config, directory, effectiveAllowRead));
1236
- }
1237
- }
1238
-
1239
- if (permission === 'glob' || permission === 'grep' || permission === 'list') {
1240
- const searchPath = typeof metadata.path === 'string' ? metadata.path : '.';
1241
- decisions.push(evaluateReadPermission(searchPath, config, directory, effectiveAllowRead));
1242
- }
1243
-
1287
+ const args: Record<string, unknown> = { ...metadata };
1288
+ if (permission === 'read') args.paths = patterns;
1244
1289
  if (permission === 'edit') {
1245
- const filepath =
1246
- typeof metadata.filepath === 'string'
1247
- ? metadata.filepath
1248
- : patterns.length === 1
1249
- ? patterns[0]
1250
- : undefined;
1251
- if (filepath) {
1252
- decisions.push(evaluateWritePermission(filepath, config, directory, effectiveAllowWrite));
1253
- }
1254
- }
1255
-
1256
- if (permission === 'bash') {
1257
- const command = typeof metadata.command === 'string' ? metadata.command : patterns[0];
1258
- if (typeof command === 'string' && !config.network.allowNetwork) {
1259
- for (const domain of extractDomainsFromCommand(command)) {
1260
- decisions.push(evaluateDomainPermission(domain, config));
1261
- }
1262
- }
1290
+ args.path = typeof metadata.filepath === 'string' ? metadata.filepath : patterns[0];
1263
1291
  }
1292
+ if (permission === 'bash' && typeof args.command !== 'string') args.command = patterns[0];
1293
+ const decisions = evaluateToolPermissions(
1294
+ permission,
1295
+ args,
1296
+ config,
1297
+ directory,
1298
+ effectiveAllowRead,
1299
+ effectiveAllowWrite,
1300
+ );
1264
1301
 
1265
1302
  const decision =
1266
1303
  decisions.find((item) => item.status === 'deny') ??
@@ -1277,49 +1314,21 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1277
1314
  const config = await activeConfig();
1278
1315
  if (!config) return;
1279
1316
 
1280
- const effectiveAllowRead = getEffectiveAllowRead(config);
1281
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1282
-
1283
1317
  if (input.tool === 'bash') {
1284
1318
  await prepareBash(input.callID, output.args, config);
1285
1319
  return;
1286
1320
  }
1287
1321
 
1288
- if (input.tool === 'read') {
1289
- const path = getToolPath(output.args);
1290
- if (path)
1291
- enforcePermission(
1292
- input.callID,
1293
- evaluateReadPermission(path, config, directory, effectiveAllowRead),
1294
- );
1295
- return;
1296
- }
1297
-
1298
- if (input.tool === 'glob' || input.tool === 'grep' || input.tool === 'list') {
1299
- enforcePermission(
1300
- input.callID,
1301
- evaluateReadPermission(getSearchPath(output.args), config, directory, effectiveAllowRead),
1302
- );
1303
- return;
1304
- }
1305
-
1306
- if (input.tool === 'write' || input.tool === 'edit') {
1307
- const path = getToolPath(output.args);
1308
- if (path)
1309
- enforcePermission(
1310
- input.callID,
1311
- evaluateWritePermission(path, config, directory, effectiveAllowWrite),
1312
- );
1313
- return;
1314
- }
1315
-
1316
- if (input.tool === 'apply_patch' && typeof output.args.patchText === 'string') {
1317
- for (const path of extractPatchPaths(output.args.patchText)) {
1318
- enforcePermission(
1319
- input.callID,
1320
- evaluateWritePermission(path, config, directory, effectiveAllowWrite),
1321
- );
1322
- }
1322
+ const decisions = evaluateToolPermissions(
1323
+ input.tool,
1324
+ output.args,
1325
+ config,
1326
+ directory,
1327
+ getEffectiveAllowRead(config),
1328
+ getEffectiveAllowWrite(config),
1329
+ );
1330
+ for (const decision of decisions) {
1331
+ enforcePermission(input.callID, decision);
1323
1332
  }
1324
1333
  },
1325
1334
 
@@ -1430,11 +1439,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1430
1439
  if (writeDecision.status === 'deny') reportBlocked(writeDecision);
1431
1440
  }
1432
1441
 
1433
- if (!config.network.allowNetwork) {
1434
- for (const domain of extractDomainsFromCommand(shellCommand)) {
1435
- const decision = evaluateDomainPermission(domain, config);
1436
- if (decision.status !== 'allow') reportBlocked(decision);
1437
- }
1442
+ for (const decision of evaluateCommandDomains(shellCommand, config)) {
1443
+ if (decision.status !== 'allow') reportBlocked(decision);
1438
1444
  }
1439
1445
  }
1440
1446
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.17.19",
3
+ "version": "0.17.21",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -37,18 +37,18 @@
37
37
  }
38
38
  },
39
39
  "scripts": {
40
- "fmt": "oxfmt index.ts tui.ts shared.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
40
+ "fmt": "oxfmt index.ts tui.ts shared.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.mjs",
41
41
  "lint": "oxlint index.ts tui.ts shared.ts",
42
42
  "check": "tsc --noEmit",
43
43
  "test": "node --test test/*.test.mjs",
44
44
  "all": "npm run fmt && npm run lint && npm run check && npm test",
45
- "ci:fmt": "oxfmt --check index.ts tui.ts shared.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
45
+ "ci:fmt": "oxfmt --check index.ts tui.ts shared.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.mjs",
46
46
  "ci:lint": "npm run lint",
47
47
  "ci:check": "npm run check",
48
48
  "ci:test": "npm test"
49
49
  },
50
50
  "dependencies": {
51
- "@landstrip/landstrip": "^0.17.19",
51
+ "@landstrip/landstrip": "^0.17.21",
52
52
  "@opentui/solid": ">=0.3.4",
53
53
  "solid-js": "1.9.12"
54
54
  },
package/tui.ts CHANGED
@@ -444,7 +444,8 @@ const tui: TuiPlugin = async (api, options, meta) => {
444
444
  }
445
445
 
446
446
  const unsubscribeAsked = api.event.on('permission.asked', (event) => {
447
- const directory = api.state.path.directory || process.cwd();
447
+ const directory = api.state.path.directory;
448
+ if (!directory) return;
448
449
  if (!loadConfig(directory, optionOverrides).enabled) return;
449
450
  const pending = event.properties as PendingPermission;
450
451
  enqueue(pending);