opencode-landstrip 0.20.15 → 0.20.17

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 (2) hide show
  1. package/package.json +2 -2
  2. package/tui.ts +74 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.20.15",
3
+ "version": "0.20.17",
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.15",
51
+ "@landstrip/landstrip-api": "^0.20.17",
52
52
  "@opentui/core": ">=0.5.12",
53
53
  "@opentui/solid": ">=0.5.12",
54
54
  "solid-js": "1.9.12"
package/tui.ts CHANGED
@@ -16,11 +16,11 @@ import {
16
16
  controlResponseLine,
17
17
  evaluateReadAccess,
18
18
  evaluateWriteAccess,
19
- isPathReadAllowed,
20
- isPathWriteAllowed,
21
19
  isQueryTrap,
20
+ matchesPathPattern,
22
21
  parseLandstripTraps,
23
- sessionScopeFor,
22
+ readGrantScopesFor,
23
+ writeGrantScopesFor,
24
24
  } from '@landstrip/landstrip-api/shared';
25
25
 
26
26
  import {
@@ -42,10 +42,6 @@ import {
42
42
 
43
43
  type QueryChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
44
44
 
45
- // No project/global option: no landstrip policy field expresses "allow this
46
- // address:port" the way allowRead/allowWrite express a path.
47
- type NetworkQueryChoice = 'once' | 'session' | 'deny';
48
-
49
45
  interface PromptOption<Value extends string> {
50
46
  label: string;
51
47
  value: Value;
@@ -441,15 +437,24 @@ const tui: TuiPlugin.Definition = {
441
437
  const verb = entry.operation
442
438
  ? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
443
439
  : 'Network';
440
+ const isUnixSocket = path.isAbsolute(entry.target);
444
441
  return jsx(LandstripPermissionPrompt, {
445
442
  icon: '%',
446
443
  title: `${verb} ${entry.target}`,
447
- options: [
448
- { label: 'Allow once', value: 'once' },
449
- { label: 'Allow for session', value: 'session' },
450
- { label: 'Deny', value: 'deny' },
451
- ],
452
- onSelect: (choice: NetworkQueryChoice) => resolveNetworkQuery(entry, choice),
444
+ options: isUnixSocket
445
+ ? [
446
+ { label: 'Allow once', value: 'once' },
447
+ { label: 'Allow for session', value: 'session' },
448
+ { label: 'Allow for project', value: 'project' },
449
+ { label: 'Allow globally', value: 'global' },
450
+ { label: 'Deny', value: 'deny' },
451
+ ]
452
+ : [
453
+ { label: 'Allow once', value: 'once' },
454
+ { label: 'Allow for session', value: 'session' },
455
+ { label: 'Deny', value: 'deny' },
456
+ ],
457
+ onSelect: (choice: QueryChoice) => resolveNetworkQuery(entry, choice),
453
458
  onCancel: () => resolveNetworkQuery(entry, 'deny'),
454
459
  onShow: () => notifyQuery(entry),
455
460
  });
@@ -469,19 +474,23 @@ const tui: TuiPlugin.Definition = {
469
474
  const verb = entry.operation === 'read' ? 'Read' : 'Write';
470
475
  const directory = entry.directory;
471
476
  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
- }
482
477
  const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
483
478
  const sessionPaths =
484
479
  entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
480
+ const scopes =
481
+ entry.operation === 'read'
482
+ ? readGrantScopesFor(
483
+ entry.path,
484
+ [...config.filesystem.allowRead, ...allowances.readPaths],
485
+ config.filesystem.denyRead,
486
+ directory,
487
+ )
488
+ : writeGrantScopesFor(
489
+ entry.path,
490
+ [...config.filesystem.allowWrite, ...allowances.writePaths],
491
+ config.filesystem.denyWrite,
492
+ directory,
493
+ );
485
494
  let responseAction: 'allow' | 'deny' = action;
486
495
  try {
487
496
  if (action === 'allow') {
@@ -489,10 +498,12 @@ const tui: TuiPlugin.Definition = {
489
498
  const { globalPath, projectPath } = getConfigPaths(directory);
490
499
  writeConfigFile(choice === 'project' ? projectPath : globalPath, {
491
500
  filesystem:
492
- entry.operation === 'read' ? { allowRead: [scope] } : { allowWrite: [scope] },
501
+ entry.operation === 'read' ? { allowRead: scopes } : { allowWrite: scopes },
493
502
  });
494
503
  }
495
- if (choice !== 'once') sessionPaths.add(scope);
504
+ if (choice !== 'once') {
505
+ for (const scope of scopes) sessionPaths.add(scope);
506
+ }
496
507
  }
497
508
  } catch {
498
509
  responseAction = 'deny';
@@ -505,7 +516,7 @@ const tui: TuiPlugin.Definition = {
505
516
  message:
506
517
  responseAction === 'deny'
507
518
  ? `${verb} denied: ${entry.path}`
508
- : `${verb} allowed (${choice}) under ${scope}`,
519
+ : `${verb} allowed (${choice}) under ${scopes.join(', ')}`,
509
520
  variant: responseAction === 'deny' ? 'warning' : 'success',
510
521
  });
511
522
  } finally {
@@ -514,23 +525,36 @@ const tui: TuiPlugin.Definition = {
514
525
  }
515
526
  }
516
527
 
517
- function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
528
+ function resolveNetworkQuery(entry: NetworkQueryEntry, choice: QueryChoice): void {
518
529
  if (resolved.has(entry.id)) return;
519
530
  const action = choice === 'deny' ? 'deny' : 'allow';
531
+ let responseAction: 'allow' | 'deny' = action;
520
532
 
521
533
  try {
522
- if (action === 'allow' && choice === 'session') {
523
- sessionAllowancesFor(sessionAllowances, entry.sessionID).targets.add(entry.target);
534
+ if (action === 'allow') {
535
+ if (path.isAbsolute(entry.target) && (choice === 'project' || choice === 'global')) {
536
+ const { globalPath, projectPath } = getConfigPaths(entry.directory);
537
+ writeConfigFile(choice === 'project' ? projectPath : globalPath, {
538
+ network: { allowUnixSockets: [entry.target] },
539
+ });
540
+ }
541
+ if (choice !== 'once') {
542
+ sessionAllowancesFor(sessionAllowances, entry.sessionID).targets.add(entry.target);
543
+ }
524
544
  }
545
+ } catch {
546
+ responseAction = 'deny';
547
+ }
525
548
 
526
- respondQuery(entry.socket, entry.queryId, action);
549
+ try {
550
+ respondQuery(entry.socket, entry.queryId, responseAction);
527
551
  context.ui.toast.show({
528
552
  title: 'Sandbox',
529
553
  message:
530
- action === 'deny'
554
+ responseAction === 'deny'
531
555
  ? `${entry.operation} denied: ${entry.target}`
532
556
  : `${entry.operation} allowed (${choice}): ${entry.target}`,
533
- variant: action === 'deny' ? 'warning' : 'success',
557
+ variant: responseAction === 'deny' ? 'warning' : 'success',
534
558
  });
535
559
  } finally {
536
560
  liveQueries.delete(entry);
@@ -605,12 +629,18 @@ const tui: TuiPlugin.Definition = {
605
629
  trap.operation === 'read'
606
630
  ? evaluateReadAccess(
607
631
  trap.path,
608
- { ...config.filesystem, allowRead: [...allowances.readPaths] },
632
+ {
633
+ ...config.filesystem,
634
+ allowRead: [...config.filesystem.allowRead, ...allowances.readPaths],
635
+ },
609
636
  { baseDirectory: sessionDirectory, requireAllowMatch: true },
610
637
  ) === 'allow'
611
638
  : evaluateWriteAccess(
612
639
  trap.path,
613
- { ...config.filesystem, allowWrite: [...allowances.writePaths] },
640
+ {
641
+ ...config.filesystem,
642
+ allowWrite: [...config.filesystem.allowWrite, ...allowances.writePaths],
643
+ },
614
644
  { baseDirectory: sessionDirectory },
615
645
  ) === 'allow';
616
646
  if (sessionAllowed) {
@@ -631,11 +661,19 @@ const tui: TuiPlugin.Definition = {
631
661
  liveQueries.add(entry);
632
662
  enqueueEntry(entry);
633
663
  } else {
634
- if (allowances.targets.has(trap.target)) {
664
+ const socketAllowed =
665
+ allowances.targets.has(trap.target) ||
666
+ (path.isAbsolute(trap.target) &&
667
+ (config.network.allowAllUnixSockets ||
668
+ matchesPathPattern(
669
+ trap.target,
670
+ config.network.allowUnixSockets,
671
+ sessionDirectory,
672
+ )));
673
+ if (socketAllowed) {
635
674
  respondQuery(socket, trap.query_id, 'allow');
636
675
  continue;
637
676
  }
638
-
639
677
  const entry: NetworkQueryEntry = {
640
678
  kind: 'net-query',
641
679
  id: `landstrip-net:${socketId}:${trap.query_id}`,