opencode-landstrip 0.16.18 → 0.16.19

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 +21 -18
  2. package/package.json +1 -1
  3. package/shared.ts +26 -3
  4. package/tui.ts +17 -6
package/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  formatLandstripTraps,
19
19
  getConfigPaths,
20
20
  isRecord,
21
+ isSandboxDisabled,
21
22
  landstripBinaryPath,
22
23
  loadConfig,
23
24
  normalizeOptions,
@@ -26,6 +27,7 @@ import {
26
27
  permissionType,
27
28
  sandboxSummary,
28
29
  sessionScopeFor,
30
+ setSandboxDisabled,
29
31
  } from './shared.js';
30
32
 
31
33
  interface LandstripPolicy {
@@ -285,19 +287,12 @@ function evaluateReadPermission(
285
287
  const allowDepth = matchDepth(filePath, effectiveAllowRead, baseDirectory);
286
288
  const denyDepth = matchDepth(filePath, config.filesystem.denyRead, baseDirectory);
287
289
 
288
- // The most specific rule wins, matching landstrip's read policy so the bash
289
- // and read tools agree: a denyRead overrides allowRead only when it is more
290
- // specific, while a tie or a more specific allowRead carves the path back in.
291
- if (denyDepth > allowDepth) {
292
- return {
293
- status: 'deny',
294
- kind: 'read',
295
- resource: filePath,
296
- message: `Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
297
- };
298
- }
299
-
300
- if (allowDepth >= 0) {
290
+ // Reads are interactive, so the read tool never hard-denies: a path covered by
291
+ // allowRead at least as specifically as any denyRead is allowed silently;
292
+ // everything else asks for approval (allow once/session/persist or reject)
293
+ // rather than being blocked outright. denyRead still hard-applies to bash
294
+ // through the landstrip binary policy, which has no way to prompt.
295
+ if (allowDepth >= 0 && allowDepth >= denyDepth) {
301
296
  return { status: 'allow', kind: 'read', resource: filePath, message: '' };
302
297
  }
303
298
 
@@ -305,7 +300,7 @@ function evaluateReadPermission(
305
300
  status: 'ask',
306
301
  kind: 'read',
307
302
  resource: filePath,
308
- message: `Sandbox: read access requires approval for "${filePath}" (not in filesystem.allowRead).`,
303
+ message: `Sandbox: read access requires approval for "${filePath}".`,
309
304
  };
310
305
  }
311
306
 
@@ -846,6 +841,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
846
841
  }
847
842
  let enabledNotified = false;
848
843
  let sandboxDisabled = false;
844
+ // A previous session may have left a disable flag for this directory; start
845
+ // enabled so /sandbox-disable stays scoped to the current session.
846
+ setSandboxDisabled(directory, false);
849
847
  let configuredShell: string | undefined;
850
848
  let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
851
849
 
@@ -899,7 +897,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
899
897
 
900
898
  function buildSandboxSummary(config: SandboxConfig): string {
901
899
  const { globalPath, projectPath } = getConfigPaths(directory);
902
- const statusText = sandboxDisabled ? 'disabled for this session' : undefined;
900
+ const statusText =
901
+ sandboxDisabled || isSandboxDisabled(directory) ? 'disabled for this session' : undefined;
903
902
  const report = sandboxSummary(config, globalPath, projectPath, statusText);
904
903
  return ['# Sandbox Configuration', '', report].join('\n');
905
904
  }
@@ -1004,7 +1003,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1004
1003
  }
1005
1004
 
1006
1005
  async function activeConfig(): Promise<SandboxConfig | null> {
1007
- if (sandboxDisabled) return null;
1006
+ // The flag file lets the TUI plugin pause the sandbox cross-process; the
1007
+ // in-memory bool covers the server's own command path.
1008
+ if (sandboxDisabled || isSandboxDisabled(directory)) return null;
1008
1009
 
1009
1010
  const config = loadConfig(directory, optionOverrides);
1010
1011
  if (!config.enabled) {
@@ -1392,7 +1393,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1392
1393
  }
1393
1394
 
1394
1395
  if (command === 'sandbox-disable') {
1395
- if (sandboxDisabled) {
1396
+ if (sandboxDisabled || isSandboxDisabled(directory)) {
1396
1397
  pushCommandText(
1397
1398
  input,
1398
1399
  output,
@@ -1401,6 +1402,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1401
1402
  return;
1402
1403
  }
1403
1404
  sandboxDisabled = true;
1405
+ setSandboxDisabled(directory, true);
1404
1406
  pushCommandText(
1405
1407
  input,
1406
1408
  output,
@@ -1419,7 +1421,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1419
1421
  }
1420
1422
 
1421
1423
  if (command === 'sandbox-enable') {
1422
- if (!sandboxDisabled) {
1424
+ if (!sandboxDisabled && !isSandboxDisabled(directory)) {
1423
1425
  pushCommandText(
1424
1426
  input,
1425
1427
  output,
@@ -1428,6 +1430,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1428
1430
  return;
1429
1431
  }
1430
1432
  sandboxDisabled = false;
1433
+ setSandboxDisabled(directory, false);
1431
1434
  const config = await activeConfig();
1432
1435
  if (!config) {
1433
1436
  pushCommandText(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.16.18",
3
+ "version": "0.16.19",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
package/shared.ts CHANGED
@@ -528,15 +528,38 @@ function discoveryDir(): string {
528
528
  return join(base, 'opencode-landstrip');
529
529
  }
530
530
 
531
- export function discoveryFilePath(baseDirectory: string): string {
531
+ function directoryHash(baseDirectory: string): string {
532
532
  let key = baseDirectory;
533
533
  try {
534
534
  key = realpathSync.native(baseDirectory);
535
535
  } catch {
536
536
  // Directory not resolvable — hash the raw path instead.
537
537
  }
538
- const hash = createHash('sha256').update(key).digest('hex').slice(0, 16);
539
- return join(discoveryDir(), `port-${hash}.json`);
538
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
539
+ }
540
+
541
+ export function discoveryFilePath(baseDirectory: string): string {
542
+ return join(discoveryDir(), `port-${directoryHash(baseDirectory)}.json`);
543
+ }
544
+
545
+ // /sandbox-disable pauses the sandbox per-directory. The flag lives beside the
546
+ // discovery file so the TUI plugin (which runs the command) and the server
547
+ // plugin (which gates wrapping) agree even though they are separate processes.
548
+ export function disableFlagPath(baseDirectory: string): string {
549
+ return join(discoveryDir(), `disabled-${directoryHash(baseDirectory)}`);
550
+ }
551
+
552
+ export function setSandboxDisabled(baseDirectory: string, disabled: boolean): void {
553
+ if (disabled) {
554
+ mkdirSync(discoveryDir(), { recursive: true, mode: 0o700 });
555
+ writeFileSync(disableFlagPath(baseDirectory), `${process.pid}\n`);
556
+ } else {
557
+ rmSync(disableFlagPath(baseDirectory), { force: true });
558
+ }
559
+ }
560
+
561
+ export function isSandboxDisabled(baseDirectory: string): boolean {
562
+ return existsSync(disableFlagPath(baseDirectory));
540
563
  }
541
564
 
542
565
  export function writeDiscoveryPort(baseDirectory: string, port: number): void {
package/tui.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  sessionAllows,
16
16
  sandboxSummary,
17
17
  sessionScopeFor,
18
+ setSandboxDisabled,
18
19
  updateForPermission,
19
20
  writeConfigFile,
20
21
  writeDiscoveryPort,
@@ -464,8 +465,18 @@ const tui: TuiPlugin = async (api, options, meta) => {
464
465
  );
465
466
  };
466
467
 
467
- const executeServerCommand = async (command: string): Promise<boolean> => {
468
- await api.client.tui.executeCommand({ command: `/${command}` });
468
+ // Toggle the per-directory disable flag directly. The server plugin gates
469
+ // wrapping on this flag, so disabling works without depending on a command
470
+ // round-trip reaching the server's command hook.
471
+ const setSandbox = (disabled: boolean): boolean => {
472
+ setSandboxDisabled(api.state.path.directory || process.cwd(), disabled);
473
+ api.ui.toast({
474
+ title: 'Sandbox',
475
+ message: disabled
476
+ ? 'Sandbox disabled for this session. Use /sandbox-enable to re-enable.'
477
+ : 'Sandbox re-enabled.',
478
+ variant: disabled ? 'warning' : 'success',
479
+ });
469
480
  return true;
470
481
  };
471
482
 
@@ -491,7 +502,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
491
502
  suggested: true,
492
503
  slash: { name: 'sandbox-disable' },
493
504
  slashName: 'sandbox-disable',
494
- run: () => executeServerCommand('sandbox-disable'),
505
+ run: () => setSandbox(true),
495
506
  },
496
507
  {
497
508
  namespace: 'palette',
@@ -502,7 +513,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
502
513
  suggested: true,
503
514
  slash: { name: 'sandbox-enable' },
504
515
  slashName: 'sandbox-enable',
505
- run: () => executeServerCommand('sandbox-enable'),
516
+ run: () => setSandbox(false),
506
517
  },
507
518
  ],
508
519
  });
@@ -524,7 +535,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
524
535
  category: 'Sandbox',
525
536
  suggested: true,
526
537
  slash: { name: 'sandbox-disable' },
527
- onSelect: () => executeServerCommand('sandbox-disable'),
538
+ onSelect: () => setSandbox(true),
528
539
  },
529
540
  {
530
541
  title: 'Enable sandbox',
@@ -533,7 +544,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
533
544
  category: 'Sandbox',
534
545
  suggested: true,
535
546
  slash: { name: 'sandbox-enable' },
536
- onSelect: () => executeServerCommand('sandbox-enable'),
547
+ onSelect: () => setSandbox(false),
537
548
  },
538
549
  ]);
539
550