opencode-landstrip 0.3.7 → 0.3.8

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 +276 -80
  2. package/package.json +2 -2
  3. package/tui.ts +12 -14
package/index.ts CHANGED
@@ -27,6 +27,7 @@ interface SandboxFilesystemConfig {
27
27
  }
28
28
 
29
29
  interface SandboxNetworkConfig {
30
+ allowNetwork: boolean;
30
31
  allowLocalBinding: boolean;
31
32
  allowAllUnixSockets: boolean;
32
33
  allowUnixSockets: string[];
@@ -42,20 +43,21 @@ interface SandboxConfig {
42
43
 
43
44
  interface LandstripPolicy {
44
45
  network: {
46
+ allowNetwork: boolean;
45
47
  allowLocalBinding: boolean;
46
48
  allowAllUnixSockets: boolean;
47
49
  allowUnixSockets: string[];
48
- httpProxyPort: number;
50
+ httpProxyPort?: number;
49
51
  };
50
52
  filesystem: SandboxFilesystemConfig;
51
53
  }
52
54
 
53
55
  interface LandstripErrorResponse {
54
- category: 'policy' | 'tool' | 'platform' | 'system';
56
+ reason: 'Other' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
55
57
  file?: string;
56
58
  program?: string;
57
59
  type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
58
- message: string;
60
+ source: string;
59
61
  }
60
62
 
61
63
  interface SandboxConfigOverrides {
@@ -68,18 +70,19 @@ interface BashSandboxState {
68
70
  originalCommand: string;
69
71
  wrappedCommand: string;
70
72
  policyDir: string;
71
- port: number;
72
- stop: () => Promise<void>;
73
+ port: number | null;
74
+ stop: (() => Promise<void>) | null;
73
75
  }
74
76
 
75
77
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
76
78
 
77
- const LANDSTRIP_VERSION = [0, 10, 1] as const;
79
+ const LANDSTRIP_VERSION = [0, 11, 0] as const;
78
80
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
79
81
 
80
82
  const DEFAULT_CONFIG: SandboxConfig = {
81
83
  enabled: true,
82
84
  network: {
85
+ allowNetwork: false,
83
86
  allowLocalBinding: false,
84
87
  allowAllUnixSockets: false,
85
88
  allowUnixSockets: [],
@@ -102,8 +105,16 @@ const DEFAULT_CONFIG: SandboxConfig = {
102
105
  },
103
106
  filesystem: {
104
107
  denyRead: ['/Users', '/home'],
105
- allowRead: ['.', '~/.config/opencode', '~/.config/git', '~/.gitconfig', '~/.local', '~/.cargo'],
106
- allowWrite: ['.', '/tmp'],
108
+ allowRead: [
109
+ '.',
110
+ '/dev/null',
111
+ '~/.config/opencode',
112
+ '~/.config/git',
113
+ '~/.gitconfig',
114
+ '~/.local',
115
+ '~/.cargo',
116
+ ],
117
+ allowWrite: ['.', '/tmp', '/dev/null'],
107
118
  denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
108
119
  },
109
120
  };
@@ -121,6 +132,7 @@ function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> |
121
132
  if (!isRecord(value)) return undefined;
122
133
 
123
134
  const config: Partial<SandboxNetworkConfig> = {};
135
+ if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
124
136
  if (typeof value.allowLocalBinding === 'boolean')
125
137
  config.allowLocalBinding = value.allowLocalBinding;
126
138
  if (typeof value.allowAllUnixSockets === 'boolean')
@@ -330,6 +342,81 @@ function allowsAllDomains(allowedDomains: string[]): boolean {
330
342
  return allowedDomains.includes('*');
331
343
  }
332
344
 
345
+ function normalizeBlockedPath(path: string, baseDirectory: string): string {
346
+ return canonicalizePath(isAbsolute(path) ? path : join(baseDirectory, path), baseDirectory);
347
+ }
348
+
349
+ function extractCandidatePaths(command: string): string[] {
350
+ const paths: string[] = [];
351
+ const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/g) ?? [];
352
+ for (const token of tokens) {
353
+ const clean = token.replace(/^["']|["']$/g, '').replace(/[,;]$/, '');
354
+ if (
355
+ clean.startsWith('/') ||
356
+ clean.startsWith('~/') ||
357
+ clean === '~' ||
358
+ clean.startsWith('./') ||
359
+ clean.startsWith('../')
360
+ ) {
361
+ paths.push(clean);
362
+ }
363
+ }
364
+ return paths;
365
+ }
366
+
367
+ function extractBlockedPath(
368
+ output: string,
369
+ baseDirectory: string,
370
+ command?: string,
371
+ ): string | null {
372
+ // bash/sh: line X: /path: Permission denied
373
+ let match = output.match(
374
+ /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
375
+ );
376
+ if (match) return normalizeBlockedPath(match[1], baseDirectory);
377
+
378
+ // ls/cat/cp: cannot open/access/stat '/path': Permission denied
379
+ match = output.match(
380
+ /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
381
+ );
382
+ if (match) return normalizeBlockedPath(match[1], baseDirectory);
383
+
384
+ // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
385
+ match = output.match(
386
+ /^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
387
+ );
388
+ if (match) return normalizeBlockedPath(match[1], baseDirectory);
389
+
390
+ // Landstrip structured error format with file field
391
+ const landstripErrors = parseLandstripErrors(output);
392
+ for (const error of landstripErrors) {
393
+ if (error.file) return normalizeBlockedPath(error.file, baseDirectory);
394
+ }
395
+
396
+ // If landstrip reported an error but without a file field, try to
397
+ // extract the blocked path from the command itself
398
+ if (landstripErrors.length > 0 && command) {
399
+ for (const candidate of extractCandidatePaths(command)) {
400
+ const resolved = canonicalizePath(candidate, baseDirectory);
401
+ return resolved;
402
+ }
403
+ }
404
+
405
+ return null;
406
+ }
407
+
408
+ function extractBlockedWritePath(
409
+ output: string,
410
+ baseDirectory: string,
411
+ command?: string,
412
+ ): string | null {
413
+ return extractBlockedPath(output, baseDirectory, command);
414
+ }
415
+
416
+ function isBlockedByDenyRead(path: string, config: SandboxConfig, baseDirectory: string): boolean {
417
+ return matchesPattern(path, config.filesystem.denyRead, baseDirectory);
418
+ }
419
+
333
420
  function firstBlockedDomain(
334
421
  command: string,
335
422
  config: SandboxConfig,
@@ -374,25 +461,38 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
374
461
  function parseLandstripErrors(output: string): LandstripErrorResponse[] {
375
462
  const errors: LandstripErrorResponse[] = [];
376
463
 
377
- for (const line of output.split('\n')) {
378
- try {
379
- const parsed = JSON.parse(line);
464
+ for (const block of output.trim().split(/\n\n+/)) {
465
+ const fields: Record<string, string> = {};
466
+
467
+ for (const line of block.split('\n')) {
468
+ const colonIndex = line.indexOf(':');
469
+ if (colonIndex === -1) continue;
470
+ const key = line.slice(0, colonIndex).trim();
471
+ const value = line.slice(colonIndex + 1).trim();
472
+ if (key.length > 0 && value.length > 0) fields[key] = value;
473
+ }
474
+
475
+ if (
476
+ fields.reason &&
477
+ ['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
478
+ fields.source
479
+ ) {
480
+ const error: LandstripErrorResponse = {
481
+ reason: fields.reason as LandstripErrorResponse['reason'],
482
+ source: fields.source,
483
+ };
484
+
485
+ if (fields.file) error.file = fields.file;
486
+ if (fields.program) error.program = fields.program;
380
487
 
381
488
  if (
382
- typeof parsed === 'object' &&
383
- parsed !== null &&
384
- typeof parsed.category === 'string' &&
385
- ['policy', 'tool', 'platform', 'system'].includes(parsed.category) &&
386
- (parsed.type === undefined ||
387
- (typeof parsed.type === 'string' &&
388
- ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(parsed.type))) &&
389
- typeof parsed.message === 'string' &&
390
- parsed.message.length > 0
489
+ fields.type &&
490
+ ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
391
491
  ) {
392
- errors.push(parsed as LandstripErrorResponse);
492
+ error.type = fields.type as LandstripErrorResponse['type'];
393
493
  }
394
- } catch {
395
- // ignore non-JSON lines
494
+
495
+ errors.push(error);
396
496
  }
397
497
  }
398
498
 
@@ -402,7 +502,7 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
402
502
  function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
403
503
  return errors
404
504
  .map((err) => {
405
- const parts: string[] = [`landstrip: ${err.category}`];
505
+ const parts: string[] = [`landstrip: ${err.reason}`];
406
506
 
407
507
  if (err.file) {
408
508
  parts.push(` (${err.file})`);
@@ -413,7 +513,7 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
413
513
  if (err.type) {
414
514
  parts.push(`:${err.type}`);
415
515
  }
416
- parts.push(`: ${err.message}`);
516
+ parts.push(`: ${err.source}`);
417
517
 
418
518
  return parts.join('');
419
519
  })
@@ -458,14 +558,15 @@ function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): vo
458
558
  function buildLandstripPolicy(
459
559
  config: SandboxConfig,
460
560
  baseDirectory: string,
461
- proxyPort: number,
561
+ proxyPort: number | null,
462
562
  ): LandstripPolicy {
463
563
  return {
464
564
  network: {
565
+ allowNetwork: config.network.allowNetwork,
465
566
  allowLocalBinding: config.network.allowLocalBinding,
466
567
  allowAllUnixSockets: config.network.allowAllUnixSockets,
467
568
  allowUnixSockets: config.network.allowUnixSockets,
468
- httpProxyPort: proxyPort,
569
+ ...(proxyPort !== null ? { httpProxyPort: proxyPort } : {}),
469
570
  },
470
571
  filesystem: resolveFilesystemConfig(config.filesystem, baseDirectory),
471
572
  };
@@ -474,7 +575,7 @@ function buildLandstripPolicy(
474
575
  function writePolicyFile(
475
576
  config: SandboxConfig,
476
577
  baseDirectory: string,
477
- proxyPort: number,
578
+ proxyPort: number | null,
478
579
  ): { dir: string; path: string } {
479
580
  const dir = mkdtempSync(join(tmpdir(), 'opencode-landstrip-XXXXXX'));
480
581
  const path = join(dir, 'policy.json');
@@ -612,7 +713,8 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
612
713
  });
613
714
  }
614
715
 
615
- function proxyEnv(port: number): Record<string, string> {
716
+ function proxyEnv(port: number | null): Record<string, string> | undefined {
717
+ if (port === null) return undefined;
616
718
  const url = `http://127.0.0.1:${port}`;
617
719
 
618
720
  return {
@@ -687,9 +789,22 @@ function errorWithConfigPaths(baseDirectory: string, message: string): Error {
687
789
  return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
688
790
  }
689
791
 
690
- function assertReadAllowed(path: string, config: SandboxConfig, baseDirectory: string): void {
792
+ function assertReadAllowed(
793
+ path: string,
794
+ config: SandboxConfig,
795
+ baseDirectory: string,
796
+ effectiveAllowRead: string[],
797
+ ): void {
691
798
  const filePath = canonicalizePath(path, baseDirectory);
692
- if (!shouldPromptForRead(filePath, config.filesystem.allowRead, baseDirectory)) return;
799
+
800
+ if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) return;
801
+
802
+ if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
803
+ throw errorWithConfigPaths(
804
+ baseDirectory,
805
+ `Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
806
+ );
807
+ }
693
808
 
694
809
  throw errorWithConfigPaths(
695
810
  baseDirectory,
@@ -697,9 +812,16 @@ function assertReadAllowed(path: string, config: SandboxConfig, baseDirectory: s
697
812
  );
698
813
  }
699
814
 
700
- function assertWriteAllowed(path: string, config: SandboxConfig, baseDirectory: string): void {
815
+ function assertWriteAllowed(
816
+ path: string,
817
+ config: SandboxConfig,
818
+ baseDirectory: string,
819
+ effectiveAllowWrite: string[],
820
+ ): void {
701
821
  const filePath = canonicalizePath(path, baseDirectory);
702
822
 
823
+ if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) return;
824
+
703
825
  if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
704
826
  throw errorWithConfigPaths(
705
827
  baseDirectory,
@@ -707,8 +829,6 @@ function assertWriteAllowed(path: string, config: SandboxConfig, baseDirectory:
707
829
  );
708
830
  }
709
831
 
710
- if (!shouldPromptForWrite(filePath, config.filesystem.allowWrite, baseDirectory)) return;
711
-
712
832
  throw errorWithConfigPaths(
713
833
  baseDirectory,
714
834
  `Sandbox: write access denied for "${filePath}" (not in filesystem.allowWrite).`,
@@ -719,20 +839,32 @@ function assertApplyPatchAllowed(
719
839
  args: Record<string, unknown>,
720
840
  config: SandboxConfig,
721
841
  baseDirectory: string,
842
+ effectiveAllowWrite: string[],
722
843
  ): void {
723
844
  if (typeof args.patchText !== 'string') return;
724
845
  for (const path of extractPatchPaths(args.patchText))
725
- assertWriteAllowed(path, config, baseDirectory);
846
+ assertWriteAllowed(path, config, baseDirectory, effectiveAllowWrite);
726
847
  }
727
848
 
728
849
  const plugin: Plugin = async ({ client, directory }: PluginInput, options?: PluginOptions) => {
729
850
  const optionOverrides = normalizeOptions(options);
730
851
  const activeBash = new Map<string, BashSandboxState>();
731
852
  const notified = new Set<string>();
853
+ const sessionAllowedDomains: string[] = [];
854
+ const sessionAllowedReadPaths: string[] = [];
855
+ const sessionAllowedWritePaths: string[] = [];
732
856
  let enabledNotified = false;
733
857
  let configuredShell: string | undefined;
734
858
  let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
735
859
 
860
+ function getEffectiveAllowRead(config: SandboxConfig): string[] {
861
+ return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
862
+ }
863
+
864
+ function getEffectiveAllowWrite(config: SandboxConfig): string[] {
865
+ return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
866
+ }
867
+
736
868
  client.app
737
869
  .log({
738
870
  body: {
@@ -801,7 +933,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
801
933
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
802
934
  landstripCheck = {
803
935
  ok: false,
804
- reason: `landstrip 0.10.1 or newer is required; found: ${version}`,
936
+ reason: `landstrip 0.11.0 or newer is required; found: ${version}`,
805
937
  };
806
938
  return landstripCheck;
807
939
  }
@@ -826,20 +958,28 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
826
958
 
827
959
  if (!enabledNotified) {
828
960
  enabledNotified = true;
829
- const networkLabel = allowsAllDomains(config.network.allowedDomains)
830
- ? 'all domains'
831
- : `${config.network.allowedDomains.length} domains`;
832
- await notifyOnce(
833
- 'enabled',
834
- `Sandbox enabled: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
835
- 'info',
836
- );
837
- if (allowsAllDomains(config.network.allowedDomains)) {
961
+ if (config.network.allowNetwork) {
838
962
  await notifyOnce(
839
- 'network-all',
840
- 'Network sandbox allows all domains because network.allowedDomains contains "*".',
963
+ 'network-allow',
964
+ 'Network sandbox is disabled because network.allowNetwork is true.',
841
965
  'warning',
842
966
  );
967
+ } else {
968
+ const networkLabel = allowsAllDomains(config.network.allowedDomains)
969
+ ? 'all domains'
970
+ : `${config.network.allowedDomains.length} domains`;
971
+ await notifyOnce(
972
+ 'enabled',
973
+ `Sandbox enabled: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
974
+ 'info',
975
+ );
976
+ if (allowsAllDomains(config.network.allowedDomains)) {
977
+ await notifyOnce(
978
+ 'network-all',
979
+ 'Network sandbox allows all domains because network.allowedDomains contains "*".',
980
+ 'warning',
981
+ );
982
+ }
843
983
  }
844
984
  }
845
985
 
@@ -851,7 +991,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
851
991
  if (!state) return;
852
992
 
853
993
  activeBash.delete(callID);
854
- await state.stop().catch(() => undefined);
994
+ if (state.stop) await state.stop().catch(() => undefined);
855
995
  rmSync(state.policyDir, { recursive: true, force: true });
856
996
  }
857
997
 
@@ -880,25 +1020,30 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
880
1020
  return;
881
1021
  }
882
1022
 
883
- const blockedDomain = firstBlockedDomain(args.command, config);
884
- if (blockedDomain) {
885
- const reason =
886
- blockedDomain.reason === 'deniedDomains'
887
- ? 'is blocked by network.deniedDomains'
888
- : 'is not in network.allowedDomains';
889
- throw errorWithConfigPaths(
890
- directory,
891
- `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
892
- );
1023
+ const allowNetwork = config.network.allowNetwork;
1024
+
1025
+ if (!allowNetwork) {
1026
+ const blockedDomain = firstBlockedDomain(args.command, config);
1027
+ if (blockedDomain) {
1028
+ const reason =
1029
+ blockedDomain.reason === 'deniedDomains'
1030
+ ? 'is blocked by network.deniedDomains'
1031
+ : 'is not in network.allowedDomains';
1032
+ throw errorWithConfigPaths(
1033
+ directory,
1034
+ `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
1035
+ );
1036
+ }
893
1037
  }
894
1038
 
895
- const proxy = await startProxy(config);
1039
+ const proxy = allowNetwork ? null : await startProxy(config);
1040
+ const proxyPort = proxy ? proxy.port : null;
896
1041
  let policy: { dir: string; path: string };
897
1042
 
898
1043
  try {
899
- policy = writePolicyFile(config, directory, proxy.port);
1044
+ policy = writePolicyFile(config, directory, proxyPort);
900
1045
  } catch (error) {
901
- await proxy.stop().catch(() => undefined);
1046
+ if (proxy) await proxy.stop().catch(() => undefined);
902
1047
  throw error;
903
1048
  }
904
1049
 
@@ -913,8 +1058,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
913
1058
  originalCommand,
914
1059
  wrappedCommand,
915
1060
  policyDir: policy.dir,
916
- port: proxy.port,
917
- stop: proxy.stop,
1061
+ port: proxyPort,
1062
+ stop: proxy ? proxy.stop : null,
918
1063
  });
919
1064
 
920
1065
  args.command = wrappedCommand;
@@ -935,6 +1080,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
935
1080
  ` Global config: ${globalPath}`,
936
1081
  ` landstrip: ${binaryPath()} (v${version})`,
937
1082
  '',
1083
+ ` Allow network: ${config.network.allowNetwork}`,
1084
+ '',
938
1085
  'Network (bash commands go through HTTP proxy):',
939
1086
  ` Allow local binding: ${config.network.allowLocalBinding}`,
940
1087
  ` Allow all Unix sockets: ${config.network.allowAllUnixSockets}`,
@@ -965,6 +1112,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
965
1112
  const config = await activeConfig();
966
1113
  if (!config) return;
967
1114
 
1115
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1116
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1117
+
968
1118
  if (input.tool === 'bash') {
969
1119
  await prepareBash(input.callID, output.args, config);
970
1120
  return;
@@ -972,22 +1122,23 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
972
1122
 
973
1123
  if (input.tool === 'read') {
974
1124
  const path = getToolPath(output.args);
975
- if (path) assertReadAllowed(path, config, directory);
1125
+ if (path) assertReadAllowed(path, config, directory, effectiveAllowRead);
976
1126
  return;
977
1127
  }
978
1128
 
979
1129
  if (input.tool === 'glob' || input.tool === 'grep' || input.tool === 'list') {
980
- assertReadAllowed(getSearchPath(output.args), config, directory);
1130
+ assertReadAllowed(getSearchPath(output.args), config, directory, effectiveAllowRead);
981
1131
  return;
982
1132
  }
983
1133
 
984
1134
  if (input.tool === 'write' || input.tool === 'edit') {
985
1135
  const path = getToolPath(output.args);
986
- if (path) assertWriteAllowed(path, config, directory);
1136
+ if (path) assertWriteAllowed(path, config, directory, effectiveAllowWrite);
987
1137
  return;
988
1138
  }
989
1139
 
990
- if (input.tool === 'apply_patch') assertApplyPatchAllowed(output.args, config, directory);
1140
+ if (input.tool === 'apply_patch')
1141
+ assertApplyPatchAllowed(output.args, config, directory, effectiveAllowWrite);
991
1142
  },
992
1143
 
993
1144
  'shell.env': async (input, output) => {
@@ -995,16 +1146,21 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
995
1146
  const state = activeBash.get(input.callID);
996
1147
  if (!state) return;
997
1148
 
998
- Object.assign(output.env, proxyEnv(state.port));
1149
+ const envVars = proxyEnv(state.port);
1150
+ if (envVars) Object.assign(output.env, envVars);
999
1151
  },
1000
1152
 
1001
1153
  'tool.execute.after': async (input, output) => {
1002
1154
  if (input.tool !== 'bash') return;
1003
1155
 
1004
1156
  const state = activeBash.get(input.callID);
1005
- if (!state) return;
1157
+ if (!state) {
1158
+ await cleanupBash(input.callID);
1159
+ return;
1160
+ }
1006
1161
 
1007
- const errors = parseLandstripErrors(output?.output ?? '');
1162
+ const outputText = output?.output ?? '';
1163
+ const errors = parseLandstripErrors(outputText);
1008
1164
  if (errors.length > 0) {
1009
1165
  const message = formatLandstripErrors(errors);
1010
1166
  await client.tui
@@ -1025,20 +1181,60 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1025
1181
  .catch(() => undefined);
1026
1182
  }
1027
1183
 
1184
+ const blockedPath = extractBlockedWritePath(outputText, directory, state.originalCommand);
1185
+ if (blockedPath) {
1186
+ const config = loadConfig(directory, optionOverrides);
1187
+ if (
1188
+ !sessionAllowedWritePaths.includes(blockedPath) &&
1189
+ !matchesPattern(blockedPath, config.filesystem.allowWrite, directory) &&
1190
+ !matchesPattern(blockedPath, config.filesystem.denyWrite, directory)
1191
+ ) {
1192
+ sessionAllowedWritePaths.push(blockedPath);
1193
+ await notifyOnce(
1194
+ `write-allow:${blockedPath}`,
1195
+ `Write access granted for session: "${blockedPath}"`,
1196
+ 'warning',
1197
+ );
1198
+ }
1199
+ }
1200
+
1028
1201
  await cleanupBash(input.callID);
1029
1202
  },
1030
1203
 
1031
1204
  'command.execute.before': async (input, output) => {
1032
- if (input.command !== 'sandbox' && input.command !== '/sandbox') return;
1033
-
1034
- const config = loadConfig(directory, optionOverrides);
1035
- const summary = buildConfigSummary(config);
1036
- await client.tui
1037
- .showToast({
1038
- body: { title: 'Sandbox', message: summary, variant: 'info', duration: 15000 },
1039
- query: { directory },
1040
- })
1041
- .catch(() => undefined);
1205
+ if (input.command === 'sandbox' || input.command === '/sandbox') {
1206
+ const config = loadConfig(directory, optionOverrides);
1207
+ const summary = buildConfigSummary(config);
1208
+ await client.tui
1209
+ .showToast({
1210
+ body: { title: 'Sandbox', message: summary, variant: 'info', duration: 15000 },
1211
+ query: { directory },
1212
+ })
1213
+ .catch(() => undefined);
1214
+ return;
1215
+ }
1216
+
1217
+ // Check domain in user shell commands (commands starting with !)
1218
+ if (input.command.startsWith('!')) {
1219
+ const shellCommand = input.command.slice(1).trim();
1220
+ const config = await activeConfig();
1221
+ if (!config || config.network.allowNetwork) return;
1222
+
1223
+ const blockedDomain = firstBlockedDomain(shellCommand, config);
1224
+ if (blockedDomain) {
1225
+ const reason =
1226
+ blockedDomain.reason === 'deniedDomains'
1227
+ ? 'is blocked by network.deniedDomains'
1228
+ : 'is not in network.allowedDomains';
1229
+ output.parts.push({
1230
+ type: 'text',
1231
+ text: `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
1232
+ id: '',
1233
+ sessionID: input.sessionID,
1234
+ messageID: '',
1235
+ });
1236
+ }
1237
+ }
1042
1238
  },
1043
1239
 
1044
1240
  dispose: async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -47,7 +47,7 @@
47
47
  "ci:test": "npm test"
48
48
  },
49
49
  "dependencies": {
50
- "@jarkkojs/landstrip": "^0.10.1"
50
+ "@jarkkojs/landstrip": "^0.11.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@opencode-ai/plugin": "^1.16.2",
package/tui.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
- const tui = async (api: any) => {
4
+ import type { TuiPlugin } from '@opencode-ai/plugin/tui';
5
+
6
+ const tui: TuiPlugin = async (api) => {
5
7
  try {
6
8
  api.keymap.registerLayer({
7
9
  commands: [
@@ -10,9 +12,11 @@ const tui = async (api: any) => {
10
12
  title: 'Sandbox',
11
13
  description: 'Show sandbox configuration',
12
14
  category: 'plugin',
15
+ keybind: 'ctrl+x b',
16
+ suggested: true,
13
17
  slash: { name: 'sandbox' },
14
18
  run: async () => {
15
- await api.client.tui.executeCommand({ body: { command: 'sandbox' } });
19
+ await api.client.tui.executeCommand({ command: 'sandbox' });
16
20
  return true;
17
21
  },
18
22
  },
@@ -23,11 +27,9 @@ const tui = async (api: any) => {
23
27
  if (client?.tui?.showToast) {
24
28
  client.tui
25
29
  .showToast({
26
- body: {
27
- title: 'Sandbox',
28
- message: '/sandbox command registered',
29
- variant: 'info',
30
- },
30
+ title: 'Sandbox',
31
+ message: '/sandbox command registered',
32
+ variant: 'info',
31
33
  })
32
34
  .catch(() => undefined);
33
35
  }
@@ -36,17 +38,13 @@ const tui = async (api: any) => {
36
38
  if (client?.tui?.showToast) {
37
39
  client.tui
38
40
  .showToast({
39
- body: {
40
- title: 'Sandbox error',
41
- message: err instanceof Error ? err.message : String(err),
42
- variant: 'error',
43
- },
41
+ title: 'Sandbox error',
42
+ message: err instanceof Error ? err.message : String(err),
43
+ variant: 'error',
44
44
  })
45
45
  .catch(() => undefined);
46
46
  }
47
47
  }
48
-
49
- return {};
50
48
  };
51
49
 
52
50
  export { tui };