opencode-landstrip 0.3.10 → 0.3.12

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/landstrip.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ declare module '@jarkkojs/landstrip' {
2
+ function binaryPath(): string;
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "index.ts",
18
18
  "tui.ts",
19
+ "landstrip.d.ts",
19
20
  "README.md",
20
21
  "sandbox.json"
21
22
  ],
@@ -47,20 +48,20 @@
47
48
  "ci:test": "npm test"
48
49
  },
49
50
  "dependencies": {
50
- "@jarkkojs/landstrip": "^0.11.0"
51
+ "@jarkkojs/landstrip": "^0.11.11"
51
52
  },
52
53
  "devDependencies": {
53
- "@opencode-ai/plugin": "^1.16.2",
54
- "@opentui/core": ">=0.3.2",
55
- "@opentui/keymap": ">=0.3.2",
56
- "@opentui/solid": ">=0.3.2",
54
+ "@opencode-ai/plugin": "^1.17.6",
55
+ "@opentui/core": ">=0.3.4",
56
+ "@opentui/keymap": ">=0.3.4",
57
+ "@opentui/solid": ">=0.3.4",
57
58
  "@types/node": "^24.0.0",
58
59
  "oxfmt": "^0.53.0",
59
60
  "oxlint": "^1.68.0",
60
61
  "typescript": "^5.8.2"
61
62
  },
62
63
  "peerDependencies": {
63
- "@opencode-ai/plugin": "^1.16.2"
64
+ "@opencode-ai/plugin": "^1.17.6"
64
65
  },
65
66
  "peerDependenciesMeta": {
66
67
  "@opencode-ai/plugin": {
@@ -68,6 +69,6 @@
68
69
  }
69
70
  },
70
71
  "engines": {
71
- "opencode": ">=1.16.2"
72
+ "opencode": ">=1.17.6"
72
73
  }
73
74
  }
package/sandbox.json CHANGED
@@ -1,57 +1,17 @@
1
1
  {
2
2
  "enabled": true,
3
3
  "network": {
4
- "allowLocalBinding": true,
4
+ "allowNetwork": false,
5
+ "allowLocalBinding": false,
5
6
  "allowAllUnixSockets": false,
6
7
  "allowUnixSockets": [],
7
- "allowedDomains": [
8
- "github.com",
9
- "*.github.com",
10
- "api.github.com",
11
- "raw.githubusercontent.com",
12
- "objects.githubusercontent.com",
13
- "codeload.github.com",
14
- "registry.npmjs.org",
15
- "npmjs.org",
16
- "*.npmjs.org",
17
- "nodejs.org",
18
- "*.nodejs.org",
19
- "crates.io",
20
- "*.crates.io",
21
- "static.crates.io"
22
- ],
8
+ "allowedDomains": [],
23
9
  "deniedDomains": []
24
10
  },
25
11
  "filesystem": {
26
- "denyRead": ["/home"],
27
- "allowRead": [
28
- ".",
29
- "/tmp",
30
- "/var/tmp",
31
- "/dev/null",
32
- "~/.config/opencode",
33
- "~/.config/git",
34
- "~/.gitconfig",
35
- "~/.local",
36
- "~/.cargo",
37
- "~/.rustup",
38
- "~/.npm",
39
- "~/.cache",
40
- "~/.bun",
41
- "~/.node-gyp"
42
- ],
43
- "allowWrite": [
44
- ".",
45
- "/tmp",
46
- "/var/tmp",
47
- "/dev/null",
48
- "~/.cargo",
49
- "~/.rustup",
50
- "~/.npm",
51
- "~/.cache",
52
- "~/.bun",
53
- "~/.node-gyp"
54
- ],
55
- "denyWrite": [".env", ".env.*", "*.pem", "*.key"]
12
+ "denyRead": ["/Users", "/home"],
13
+ "allowRead": [".", "~/.gitconfig", "/dev/null"],
14
+ "allowWrite": [".", "/dev/null"],
15
+ "denyWrite": ["**/.env", "**/.env.*", "**/*.pem", "**/*.key"]
56
16
  }
57
17
  }
package/tui.ts CHANGED
@@ -5,9 +5,9 @@ import type { TuiPlugin } from '@opencode-ai/plugin/tui';
5
5
 
6
6
  import { binaryPath } from '@jarkkojs/landstrip';
7
7
 
8
- import { existsSync, readFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
- import { join } from 'node:path';
10
+ import { dirname, join } from 'node:path';
11
11
 
12
12
  interface SandboxFilesystemConfig {
13
13
  denyRead: string[];
@@ -44,38 +44,23 @@ const DEFAULT_CONFIG: SandboxConfig = {
44
44
  allowLocalBinding: false,
45
45
  allowAllUnixSockets: false,
46
46
  allowUnixSockets: [],
47
- allowedDomains: [
48
- 'npmjs.org',
49
- '*.npmjs.org',
50
- 'registry.npmjs.org',
51
- 'registry.yarnpkg.com',
52
- 'pypi.org',
53
- '*.pypi.org',
54
- 'github.com',
55
- '*.github.com',
56
- 'api.github.com',
57
- 'raw.githubusercontent.com',
58
- 'crates.io',
59
- '*.crates.io',
60
- 'static.crates.io',
61
- ],
47
+ allowedDomains: [],
62
48
  deniedDomains: [],
63
49
  },
64
50
  filesystem: {
65
51
  denyRead: ['/Users', '/home'],
66
- allowRead: [
67
- '.',
68
- '/dev/null',
69
- '~/.config/opencode',
70
- '~/.config/git',
71
- '~/.gitconfig',
72
- '~/.local',
73
- '~/.cargo',
74
- ],
75
- allowWrite: ['.', '/tmp', '/dev/null'],
76
- denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
52
+ allowRead: ['.', '~/.gitconfig', '/dev/null'],
53
+ allowWrite: ['.', '/dev/null'],
54
+ denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
77
55
  },
78
56
  };
57
+ const LANDSTRIP_PACKAGE_NAMES = new Set([
58
+ '@jarkkojs/landstrip',
59
+ '@jarkkojs/landstrip-darwin-arm64',
60
+ '@jarkkojs/landstrip-darwin-x64',
61
+ '@jarkkojs/landstrip-linux-x64',
62
+ '@jarkkojs/landstrip-win32-x64',
63
+ ]);
79
64
 
80
65
  function isRecord(value: unknown): value is Record<string, unknown> {
81
66
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -147,16 +132,30 @@ function normalizeOptions(options: unknown): SandboxConfigOverrides {
147
132
  return normalizeConfig(isRecord(options.config) ? options.config : options);
148
133
  }
149
134
 
135
+ function mergeArray(base: string[], override?: string[]): string[] {
136
+ if (!override) return base;
137
+ return [...new Set([...base, ...override])];
138
+ }
139
+
150
140
  function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
141
+ const network = overrides.network;
142
+ const filesystem = overrides.filesystem;
143
+
151
144
  return {
152
145
  enabled: overrides.enabled ?? base.enabled,
153
146
  network: {
154
- ...base.network,
155
- ...overrides.network,
147
+ allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
148
+ allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
149
+ allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
150
+ allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
151
+ allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
152
+ deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
156
153
  },
157
154
  filesystem: {
158
- ...base.filesystem,
159
- ...overrides.filesystem,
155
+ denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
156
+ allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
157
+ allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
158
+ denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
160
159
  },
161
160
  };
162
161
  }
@@ -168,20 +167,61 @@ function getConfigPaths(baseDirectory: string): { globalPath: string; projectPat
168
167
  };
169
168
  }
170
169
 
171
- function readConfigFile(configPath: string): SandboxConfigOverrides {
170
+ function readConfigFile(configPath: string): SandboxConfigOverrides | null {
172
171
  if (!existsSync(configPath)) return {};
173
172
 
174
173
  try {
175
174
  return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
176
175
  } catch {
177
- return {};
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function landstripBinaryPath(): string {
181
+ const filePath = realpathSync.native(binaryPath());
182
+ let probe = dirname(filePath);
183
+
184
+ while (true) {
185
+ const manifestPath = join(probe, 'package.json');
186
+ if (existsSync(manifestPath)) {
187
+ try {
188
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
189
+ if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
190
+ return filePath;
191
+ }
192
+ } catch {
193
+ // malformed package.json — continue walking to parent
194
+ }
195
+ }
196
+
197
+ const parent = dirname(probe);
198
+ if (parent === probe) break;
199
+ probe = parent;
200
+ }
201
+
202
+ throw new Error(
203
+ `Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
204
+ );
205
+ }
206
+
207
+ function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
208
+ const current = readConfigFile(configPath);
209
+ if (current === null) {
210
+ throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
178
211
  }
212
+
213
+ const next = deepMerge(deepMerge(DEFAULT_CONFIG, current), update);
214
+
215
+ mkdirSync(dirname(configPath), { recursive: true });
216
+ writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
179
217
  }
180
218
 
181
219
  function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
182
220
  const { globalPath, projectPath } = getConfigPaths(baseDirectory);
221
+ const globalConfig = readConfigFile(globalPath);
222
+ const projectConfig = readConfigFile(projectPath);
183
223
  return deepMerge(
184
- deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
224
+ deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig ?? {}), projectConfig ?? {}),
185
225
  optionOverrides,
186
226
  );
187
227
  }
@@ -201,7 +241,7 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
201
241
 
202
242
  return [
203
243
  `Status: ${config.enabled ? 'active' : 'disabled by config'}`,
204
- `landstrip: ${binaryPath()}`,
244
+ `landstrip package binary: ${landstripBinaryPath()}`,
205
245
  '',
206
246
  'Config files',
207
247
  configPathLine('project', projectPath),
@@ -223,7 +263,150 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
223
263
  ].join('\n');
224
264
  }
225
265
 
266
+ type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
267
+
268
+ function permissionType(permission: Record<string, unknown>, fallback = ''): string {
269
+ if (typeof permission.permission === 'string') return permission.permission;
270
+ if (typeof permission.action === 'string') return permission.action;
271
+ if (typeof permission.type === 'string') return permission.type;
272
+ return fallback;
273
+ }
274
+
275
+ function permissionPattern(permission: Record<string, unknown>): string | undefined {
276
+ const patterns = permission.patterns;
277
+ if (Array.isArray(patterns))
278
+ return patterns.find((item): item is string => typeof item === 'string');
279
+
280
+ const pattern = permission.pattern;
281
+ if (typeof pattern === 'string') return pattern;
282
+ if (Array.isArray(pattern))
283
+ return pattern.find((item): item is string => typeof item === 'string');
284
+
285
+ return undefined;
286
+ }
287
+
288
+ function domainsFromCommand(command: string): string[] {
289
+ const domains = new Set<string>();
290
+ const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
291
+ let match: RegExpExecArray | null;
292
+
293
+ while ((match = urlRegex.exec(command)) !== null) domains.add(match[1]);
294
+
295
+ return [...domains];
296
+ }
297
+
298
+ function updateForPermission(permission: Record<string, unknown>): SandboxConfigOverrides | null {
299
+ const metadata = isRecord(permission.metadata) ? permission.metadata : {};
300
+ const type = permissionType(permission);
301
+ const pattern = permissionPattern(permission);
302
+
303
+ if (type === 'bash') {
304
+ const command = typeof metadata.command === 'string' ? metadata.command : pattern;
305
+ const domains = typeof command === 'string' ? domainsFromCommand(command) : [];
306
+ return domains.length > 0 ? { network: { allowedDomains: domains } } : null;
307
+ }
308
+
309
+ if (type === 'read' || type === 'glob' || type === 'grep' || type === 'list') {
310
+ const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
311
+ return filePath ? { filesystem: { allowRead: [filePath] } } : null;
312
+ }
313
+
314
+ if (type === 'edit' || type === 'write' || type === 'apply_patch') {
315
+ const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
316
+ return filePath ? { filesystem: { allowWrite: [filePath] } } : null;
317
+ }
318
+
319
+ return null;
320
+ }
321
+
322
+ function permissionLabel(permission: Record<string, unknown>): string {
323
+ const type = permissionType(permission, 'permission');
324
+ const title = typeof permission.title === 'string' ? permission.title : type;
325
+ const pattern = permissionPattern(permission);
326
+ return pattern ? `${title}: ${pattern}` : title;
327
+ }
328
+
226
329
  const tui: TuiPlugin = async (api, options) => {
330
+ const handledPermissions = new Set<string>();
331
+
332
+ async function replyPermission(
333
+ permission: Record<string, unknown>,
334
+ choice: PermissionChoice,
335
+ ): Promise<void> {
336
+ const id = typeof permission.id === 'string' ? permission.id : undefined;
337
+ if (!id || typeof permission.sessionID !== 'string') return;
338
+
339
+ const directory = api.state.path.directory || process.cwd();
340
+ const { globalPath, projectPath } = getConfigPaths(directory);
341
+
342
+ try {
343
+ if (choice === 'project' || choice === 'global') {
344
+ const update = updateForPermission(permission);
345
+ if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
346
+ }
347
+
348
+ await api.client.permission.reply({
349
+ requestID: id,
350
+ reply: choice === 'reject' ? 'reject' : choice === 'once' ? 'once' : 'always',
351
+ });
352
+
353
+ api.ui.toast({
354
+ title: 'Sandbox',
355
+ message: choice === 'reject' ? 'Permission rejected' : `Permission allowed for ${choice}`,
356
+ variant: choice === 'reject' ? 'warning' : 'success',
357
+ });
358
+ } catch {
359
+ api.ui.toast({
360
+ title: 'Sandbox',
361
+ message: 'Permission was already handled or could not be updated',
362
+ variant: 'warning',
363
+ });
364
+ } finally {
365
+ api.ui.dialog.clear();
366
+ }
367
+ }
368
+
369
+ function showPermission(permission: Record<string, unknown>): void {
370
+ const id = typeof permission.id === 'string' ? permission.id : undefined;
371
+ if (!id || handledPermissions.has(id)) return;
372
+ handledPermissions.add(id);
373
+
374
+ api.ui.dialog.replace(
375
+ () =>
376
+ api.ui.DialogSelect<PermissionChoice>({
377
+ title: 'Sandbox Permission',
378
+ placeholder: permissionLabel(permission),
379
+ options: [
380
+ { title: 'Allow once', value: 'once', description: 'Approve only this request' },
381
+ {
382
+ title: 'Allow for session',
383
+ value: 'session',
384
+ description: 'Use OpenCode session approval for matching requests',
385
+ },
386
+ {
387
+ title: 'Allow for project',
388
+ value: 'project',
389
+ description: 'Persist to .opencode/sandbox.json and approve this session',
390
+ },
391
+ {
392
+ title: 'Allow globally',
393
+ value: 'global',
394
+ description: 'Persist to ~/.config/opencode/sandbox.json and approve this session',
395
+ },
396
+ { title: 'Reject', value: 'reject', description: 'Deny this request' },
397
+ ],
398
+ onSelect: (option) => {
399
+ void replyPermission(permission, option.value);
400
+ },
401
+ }),
402
+ () => api.ui.dialog.clear(),
403
+ );
404
+ }
405
+
406
+ api.event.on('permission.asked', (event) => {
407
+ showPermission(event.properties as Record<string, unknown>);
408
+ });
409
+
227
410
  const showSandbox = () => {
228
411
  const directory = api.state.path.directory || process.cwd();
229
412
  const message = sandboxSummary(directory, normalizeOptions(options));
@@ -239,32 +422,71 @@ const tui: TuiPlugin = async (api, options) => {
239
422
  );
240
423
  };
241
424
 
425
+ const executeServerCommand = async (command: string): Promise<boolean> => {
426
+ await api.client.tui.executeCommand({ command });
427
+ return true;
428
+ };
429
+
242
430
  api.keymap.registerLayer({
243
431
  commands: [
244
432
  {
245
- namespace: 'palette',
246
- name: 'landstrip.sandbox.show',
247
- title: 'Show sandbox configuration',
248
- desc: 'Show landstrip sandbox status and rules',
249
- description: 'Show landstrip sandbox status and rules',
433
+ name: 'sandbox',
434
+ title: 'Sandbox',
435
+ description: 'Show sandbox configuration',
250
436
  category: 'Sandbox',
251
437
  suggested: true,
252
- slashName: 'sandbox',
438
+ slash: { name: 'sandbox' },
253
439
  run: showSandbox,
254
440
  },
441
+ {
442
+ name: 'sandbox-disable',
443
+ title: 'Disable sandbox',
444
+ description: 'Disable sandbox for this session',
445
+ category: 'Sandbox',
446
+ suggested: true,
447
+ slash: { name: 'sandbox-disable' },
448
+ run: () => executeServerCommand('sandbox-disable'),
449
+ },
450
+ {
451
+ name: 'sandbox-enable',
452
+ title: 'Enable sandbox',
453
+ description: 'Re-enable sandbox for this session',
454
+ category: 'Sandbox',
455
+ suggested: true,
456
+ slash: { name: 'sandbox-enable' },
457
+ run: () => executeServerCommand('sandbox-enable'),
458
+ },
255
459
  ],
256
460
  });
257
461
 
258
462
  api.command?.register(() => [
259
463
  {
260
464
  title: 'Sandbox',
261
- value: 'landstrip.sandbox.show',
465
+ value: 'sandbox',
262
466
  description: 'Show sandbox configuration',
263
467
  category: 'Sandbox',
264
468
  suggested: true,
265
469
  slash: { name: 'sandbox' },
266
470
  onSelect: showSandbox,
267
471
  },
472
+ {
473
+ title: 'Disable sandbox',
474
+ value: 'sandbox-disable',
475
+ description: 'Disable sandbox for this session',
476
+ category: 'Sandbox',
477
+ suggested: true,
478
+ slash: { name: 'sandbox-disable' },
479
+ onSelect: () => executeServerCommand('sandbox-disable'),
480
+ },
481
+ {
482
+ title: 'Enable sandbox',
483
+ value: 'sandbox-enable',
484
+ description: 'Re-enable sandbox for this session',
485
+ category: 'Sandbox',
486
+ suggested: true,
487
+ slash: { name: 'sandbox-enable' },
488
+ onSelect: () => executeServerCommand('sandbox-enable'),
489
+ },
268
490
  ]);
269
491
  };
270
492