opencode-landstrip 0.16.5 → 0.16.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.
Files changed (4) hide show
  1. package/index.ts +23 -40
  2. package/package.json +2 -2
  3. package/shared.ts +75 -18
  4. package/tui.ts +9 -40
package/index.ts CHANGED
@@ -11,7 +11,6 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
11
11
  import { URL } from 'node:url';
12
12
 
13
13
  import {
14
- list,
15
14
  type LandstripTrap,
16
15
  type SandboxConfig,
17
16
  type SandboxFilesystemConfig,
@@ -26,6 +25,7 @@ import {
26
25
  permissionPatterns,
27
26
  permissionType,
28
27
  readDiscoveryPort,
28
+ sandboxSummary,
29
29
  } from './shared.js';
30
30
 
31
31
  interface LandstripPolicy {
@@ -96,12 +96,17 @@ function canonicalizePath(filePath: string, baseDirectory: string): string {
96
96
  }
97
97
  }
98
98
 
99
+ const globRegExpCache = new Map<string, RegExp>();
100
+
99
101
  /**
100
102
  * Translates an absolute glob pattern to a regular expression using standard
101
103
  * path semantics: `**` crosses directory boundaries (and `**​/` may match zero
102
104
  * segments), while a single `*` is confined to one path segment.
103
105
  */
104
106
  function globToRegExp(globPattern: string): RegExp {
107
+ const cached = globRegExpCache.get(globPattern);
108
+ if (cached) return cached;
109
+
105
110
  let regex = '';
106
111
 
107
112
  for (let i = 0; i < globPattern.length; i++) {
@@ -125,7 +130,9 @@ function globToRegExp(globPattern: string): RegExp {
125
130
  }
126
131
  }
127
132
 
128
- return new RegExp(`^${regex}$`);
133
+ const result = new RegExp(`^${regex}$`);
134
+ globRegExpCache.set(globPattern, result);
135
+ return result;
129
136
  }
130
137
 
131
138
  // Component count of an absolute path; "/" is 0. Used to rank how specific a
@@ -708,10 +715,8 @@ function splitShellQuotedArgs(command: string): string[] {
708
715
  function extractOriginalCommand(wrappedCommand: string): string | null {
709
716
  const args = splitShellQuotedArgs(wrappedCommand);
710
717
  const pIdx = args.indexOf('-p');
711
- if (pIdx === -1 || pIdx + 3 >= args.length) return null;
712
- const flagIdx = pIdx + 3;
713
- const flag = args[flagIdx];
714
- if (flag !== '-lc' && flag !== '-c') return null;
718
+ const flagIdx = args.findIndex((arg, i) => i > pIdx && (arg === '-lc' || arg === '-c'));
719
+ if (flagIdx === -1) return null;
715
720
  // The query-response form appends `|| <plain invocation>`; stop at that
716
721
  // separator so the fallback branch is not folded into the recovered command.
717
722
  const end = args.indexOf('||', flagIdx + 1);
@@ -807,37 +812,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
807
812
  });
808
813
  }
809
814
 
810
- function sandboxSummary(config: SandboxConfig): string {
815
+ function buildSandboxSummary(config: SandboxConfig): string {
811
816
  const { globalPath, projectPath } = getConfigPaths(directory);
812
- const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
813
- const allowed = list(config.network.allowedDomains);
814
- const denied = list(config.network.deniedDomains);
815
- const denyRead = list(config.filesystem.denyRead);
816
- const allowRead = list(config.filesystem.allowRead);
817
- const allowWrite = list(config.filesystem.allowWrite);
818
- const denyWrite = list(config.filesystem.denyWrite);
819
-
820
- return [
821
- '# Sandbox Configuration',
822
- '',
823
- `Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
824
- `landstrip package binary: ${landstripBinaryPath()}`,
825
- '',
826
- 'Config files:',
827
- `- project: ${projectPath}`,
828
- `- global: ${globalPath}`,
829
- '',
830
- `Network (${networkMode}):`,
831
- `- allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
832
- `- allowed: ${allowed}`,
833
- `- denied: ${denied}`,
834
- '',
835
- 'Filesystem:',
836
- `- deny read: ${denyRead}`,
837
- `- allow read: ${allowRead}`,
838
- `- allow write: ${allowWrite}`,
839
- `- deny write: ${denyWrite}`,
840
- ].join('\n');
817
+ const statusText = sandboxDisabled ? 'disabled for this session' : undefined;
818
+ const report = sandboxSummary(config, globalPath, projectPath, statusText);
819
+ return ['# Sandbox Configuration', '', report].join('\n');
841
820
  }
842
821
 
843
822
  client.app
@@ -1273,9 +1252,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1273
1252
  },
1274
1253
 
1275
1254
  'command.execute.before': async (input, output) => {
1276
- if (input.command.trim() === '/sandbox') {
1255
+ // OpenCode strips the leading slash before dispatching commands, so the
1256
+ // hook receives the bare name ("sandbox"); accept both forms so the
1257
+ // handler matches whether invoked by name or via tui.executeCommand.
1258
+ const command = input.command.trim().replace(/^\//, '');
1259
+ if (command === 'sandbox') {
1277
1260
  const config = loadConfig(directory, optionOverrides);
1278
- pushCommandText(input, output, sandboxSummary(config));
1261
+ pushCommandText(input, output, buildSandboxSummary(config));
1279
1262
  await client.tui
1280
1263
  ?.showToast?.({
1281
1264
  body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
@@ -1284,7 +1267,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1284
1267
  return;
1285
1268
  }
1286
1269
 
1287
- if (input.command.trim() === '/sandbox-disable') {
1270
+ if (command === 'sandbox-disable') {
1288
1271
  if (sandboxDisabled) {
1289
1272
  pushCommandText(
1290
1273
  input,
@@ -1311,7 +1294,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1311
1294
  return;
1312
1295
  }
1313
1296
 
1314
- if (input.command.trim() === '/sandbox-enable') {
1297
+ if (command === 'sandbox-enable') {
1315
1298
  if (!sandboxDisabled) {
1316
1299
  pushCommandText(
1317
1300
  input,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.16.5",
3
+ "version": "0.16.7",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -49,7 +49,7 @@
49
49
  "ci:test": "npm test"
50
50
  },
51
51
  "dependencies": {
52
- "@landstrip/landstrip": "^0.16.8"
52
+ "@landstrip/landstrip": "^0.16.11"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@opencode-ai/plugin": "^1.17.7",
package/shared.ts CHANGED
@@ -209,31 +209,43 @@ export function writeConfigFile(configPath: string, update: SandboxConfigOverrid
209
209
  writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
210
210
  }
211
211
 
212
+ let _landstripBinaryPath: string | undefined;
213
+ let _landstripBinaryPathError: unknown;
214
+
212
215
  export function landstripBinaryPath(): string {
213
- const filePath = realpathSync.native(binaryPath());
214
- let probe = dirname(filePath);
216
+ if (_landstripBinaryPath !== undefined) return _landstripBinaryPath;
217
+ if (_landstripBinaryPathError !== undefined) throw _landstripBinaryPathError;
215
218
 
216
- while (true) {
217
- const manifestPath = join(probe, 'package.json');
218
- if (existsSync(manifestPath)) {
219
- try {
220
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
221
- if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
222
- return filePath;
219
+ try {
220
+ const filePath = realpathSync.native(binaryPath());
221
+ let probe = dirname(filePath);
222
+
223
+ while (true) {
224
+ const manifestPath = join(probe, 'package.json');
225
+ if (existsSync(manifestPath)) {
226
+ try {
227
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
228
+ if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
229
+ _landstripBinaryPath = filePath;
230
+ return filePath;
231
+ }
232
+ } catch {
233
+ // malformed package.json — continue walking to parent
223
234
  }
224
- } catch {
225
- // malformed package.json — continue walking to parent
226
235
  }
236
+
237
+ const parent = dirname(probe);
238
+ if (parent === probe) break;
239
+ probe = parent;
227
240
  }
228
241
 
229
- const parent = dirname(probe);
230
- if (parent === probe) break;
231
- probe = parent;
242
+ throw new Error(
243
+ `Refusing to use landstrip binary outside official @landstrip/landstrip packages: ${filePath}`,
244
+ );
245
+ } catch (error) {
246
+ _landstripBinaryPathError = error;
247
+ throw error;
232
248
  }
233
-
234
- throw new Error(
235
- `Refusing to use landstrip binary outside official @landstrip/landstrip packages: ${filePath}`,
236
- );
237
249
  }
238
250
 
239
251
  export function extractDomainsFromCommand(command: string): string[] {
@@ -529,3 +541,48 @@ export function readDiscoveryPort(baseDirectory: string): number | null {
529
541
  return null;
530
542
  }
531
543
  }
544
+
545
+ /**
546
+ * Human-readable sandbox configuration report consumed by both the server
547
+ * command and the TUI inspector dialog.
548
+ */
549
+ export function sandboxSummary(
550
+ config: SandboxConfig,
551
+ globalPath: string,
552
+ projectPath: string,
553
+ statusOverride?: string,
554
+ ): string {
555
+ const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
556
+ const allowed = list(config.network.allowedDomains);
557
+ const denied = list(config.network.deniedDomains);
558
+ const unixSockets = config.network.allowAllUnixSockets
559
+ ? 'all'
560
+ : list(config.network.allowUnixSockets);
561
+ const denyRead = list(config.filesystem.denyRead);
562
+ const allowRead = list(config.filesystem.allowRead);
563
+ const allowWrite = list(config.filesystem.allowWrite);
564
+ const denyWrite = list(config.filesystem.denyWrite);
565
+
566
+ const status = statusOverride ?? (config.enabled ? 'active' : 'disabled by config');
567
+
568
+ return [
569
+ `Status: ${status}`,
570
+ `landstrip package binary: ${landstripBinaryPath()}`,
571
+ '',
572
+ 'Config files',
573
+ `${projectPath} ${existsSync(projectPath) ? '(found)' : '(missing)'}`,
574
+ `${globalPath} ${existsSync(globalPath) ? '(found)' : '(missing)'}`,
575
+ '',
576
+ `Network: ${networkMode}`,
577
+ `allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
578
+ `allowed: ${allowed}`,
579
+ `denied: ${denied}`,
580
+ `unix sockets: ${unixSockets}`,
581
+ '',
582
+ 'Filesystem',
583
+ `deny read: ${denyRead}`,
584
+ `allow read: ${allowRead}`,
585
+ `allow write: ${allowWrite}`,
586
+ `deny write: ${denyWrite}`,
587
+ ].join('\n');
588
+ }
package/tui.ts CHANGED
@@ -2,21 +2,17 @@
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
4
  import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
5
-
6
- import { existsSync } from 'node:fs';
7
5
  import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
8
6
 
9
7
  import {
10
- list,
11
- type SandboxConfigOverrides,
12
8
  getConfigPaths,
13
- landstripBinaryPath,
14
9
  loadConfig,
15
10
  normalizeOptions,
16
11
  parseLandstripTraps,
17
12
  permissionLabel,
18
13
  permissionResource,
19
14
  removeDiscoveryFile,
15
+ sandboxSummary,
20
16
  updateForPermission,
21
17
  writeConfigFile,
22
18
  writeDiscoveryPort,
@@ -58,39 +54,6 @@ interface PermissionEntry {
58
54
 
59
55
  type QueueEntry = PermissionEntry | FsQueryEntry;
60
56
 
61
- function configPathLine(label: string, filePath: string): string {
62
- return `${label}: ${filePath} ${existsSync(filePath) ? '(found)' : '(missing)'}`;
63
- }
64
-
65
- function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOverrides): string {
66
- const config = loadConfig(baseDirectory, optionOverrides);
67
- const { globalPath, projectPath } = getConfigPaths(baseDirectory);
68
- const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
69
-
70
- return [
71
- `Status: ${config.enabled ? 'active' : 'disabled by config'}`,
72
- `landstrip package binary: ${landstripBinaryPath()}`,
73
- '',
74
- 'Config files',
75
- configPathLine('project', projectPath),
76
- configPathLine('global', globalPath),
77
- '',
78
- `Network: ${networkMode}`,
79
- `allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
80
- `allowed: ${list(config.network.allowedDomains)}`,
81
- `denied: ${list(config.network.deniedDomains)}`,
82
- `unix sockets: ${config.network.allowAllUnixSockets ? 'all' : list(config.network.allowUnixSockets)}`,
83
- '',
84
- 'Filesystem',
85
- `deny read: ${list(config.filesystem.denyRead)}`,
86
- `allow read: ${list(config.filesystem.allowRead)}`,
87
- `allow write: ${list(config.filesystem.allowWrite)}`,
88
- `deny write: ${list(config.filesystem.denyWrite)}`,
89
- '',
90
- 'Press esc or enter to close',
91
- ].join('\n');
92
- }
93
-
94
57
  function asRecord(permission: PendingPermission): Record<string, unknown> {
95
58
  return permission as unknown as Record<string, unknown>;
96
59
  }
@@ -475,7 +438,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
475
438
 
476
439
  const showSandbox = () => {
477
440
  const directory = api.state.path.directory || process.cwd();
478
- const message = sandboxSummary(directory, optionOverrides);
441
+ const config = loadConfig(directory, optionOverrides);
442
+ const { globalPath, projectPath } = getConfigPaths(directory);
443
+ const message =
444
+ sandboxSummary(config, globalPath, projectPath) + '\n\nPress esc or enter to close';
479
445
 
480
446
  // No `onConfirm`/`onClose` that call `clear()`: the host already pops the
481
447
  // dialog on enter/esc/click, and its `clear()` re-invokes every entry's
@@ -502,6 +468,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
502
468
  desc: 'Show sandbox configuration',
503
469
  category: 'Sandbox',
504
470
  suggested: true,
471
+ slash: { name: 'sandbox' },
505
472
  slashName: 'sandbox',
506
473
  run: showSandbox,
507
474
  },
@@ -512,6 +479,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
512
479
  desc: 'Disable sandbox for this session',
513
480
  category: 'Sandbox',
514
481
  suggested: true,
482
+ slash: { name: 'sandbox-disable' },
515
483
  slashName: 'sandbox-disable',
516
484
  run: () => executeServerCommand('sandbox-disable'),
517
485
  },
@@ -522,6 +490,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
522
490
  desc: 'Re-enable sandbox for this session',
523
491
  category: 'Sandbox',
524
492
  suggested: true,
493
+ slash: { name: 'sandbox-enable' },
525
494
  slashName: 'sandbox-enable',
526
495
  run: () => executeServerCommand('sandbox-enable'),
527
496
  },
@@ -624,4 +593,4 @@ const tui: TuiPlugin = async (api, options, meta) => {
624
593
  };
625
594
 
626
595
  export { tui };
627
- export default { tui };
596
+ export default { id: 'opencode-landstrip', tui };