opencode-landstrip 0.3.9 → 0.3.11

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/README.md CHANGED
@@ -14,6 +14,18 @@ Add the plugin to `opencode.json`:
14
14
  }
15
15
  ```
16
16
 
17
+ Add the TUI entrypoint to `tui.json` if you install or configure the plugin
18
+ manually:
19
+
20
+ ```json
21
+ {
22
+ "$schema": "https://opencode.ai/config.json",
23
+ "plugin": ["opencode-landstrip"]
24
+ }
25
+ ```
26
+
27
+ `opencode plugin install opencode-landstrip` configures both entrypoints.
28
+
17
29
  This installs `opencode-landstrip` and its `@jarkkojs/landstrip` dependency, which
18
30
  includes platform-specific native binaries for Linux, macOS, and Windows.
19
31
 
@@ -32,9 +44,12 @@ The plugin wraps opencode's AI `bash` tool in `landstrip`, routes proxy-aware
32
44
  network traffic through an allowlist proxy, and blocks read/write tool access
33
45
  outside configured filesystem allowlists.
34
46
 
47
+ Run `/sandbox` in the TUI to inspect the active sandbox configuration.
48
+
35
49
  opencode's current server plugin API does not expose Pi-style custom permission
36
- dialogs or a way to rewrite manually typed shell-mode commands. Blocked access
37
- fails with an error that points at the sandbox config files.
50
+ dialogs or a way to rewrite manually typed shell-mode commands. The `/sandbox`
51
+ status view is provided by the TUI plugin entrypoint. Blocked access fails with
52
+ an error that points at the sandbox config files.
38
53
 
39
54
  ## Disable
40
55
 
package/index.ts CHANGED
@@ -74,6 +74,15 @@ interface BashSandboxState {
74
74
  stop: (() => Promise<void>) | null;
75
75
  }
76
76
 
77
+ type SandboxPermissionKind = 'read' | 'write' | 'domain';
78
+
79
+ interface SandboxPermissionDecision {
80
+ status: 'allow' | 'ask' | 'deny';
81
+ kind: SandboxPermissionKind;
82
+ resource: string;
83
+ message: string;
84
+ }
85
+
77
86
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
78
87
 
79
88
  const LANDSTRIP_VERSION = [0, 11, 0] as const;
@@ -310,7 +319,7 @@ function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory:
310
319
  }
311
320
 
312
321
  function extractDomainsFromCommand(command: string): string[] {
313
- const urlRegex = /https?:\/\/([^\s/:?#]+)(?::\d+)?(?:[/?#]|\s|$)/g;
322
+ const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
314
323
  const domains = new Set<string>();
315
324
  let match: RegExpExecArray | null;
316
325
 
@@ -434,6 +443,89 @@ function firstBlockedDomain(
434
443
  return null;
435
444
  }
436
445
 
446
+ function evaluateReadPermission(
447
+ path: string,
448
+ config: SandboxConfig,
449
+ baseDirectory: string,
450
+ effectiveAllowRead: string[],
451
+ ): SandboxPermissionDecision {
452
+ const filePath = canonicalizePath(path, baseDirectory);
453
+
454
+ if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
455
+ return { status: 'allow', kind: 'read', resource: filePath, message: '' };
456
+ }
457
+
458
+ if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
459
+ return {
460
+ status: 'deny',
461
+ kind: 'read',
462
+ resource: filePath,
463
+ message: `Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
464
+ };
465
+ }
466
+
467
+ return {
468
+ status: 'ask',
469
+ kind: 'read',
470
+ resource: filePath,
471
+ message: `Sandbox: read access requires approval for "${filePath}" (not in filesystem.allowRead).`,
472
+ };
473
+ }
474
+
475
+ function evaluateWritePermission(
476
+ path: string,
477
+ config: SandboxConfig,
478
+ baseDirectory: string,
479
+ effectiveAllowWrite: string[],
480
+ ): SandboxPermissionDecision {
481
+ const filePath = canonicalizePath(path, baseDirectory);
482
+
483
+ if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
484
+ return { status: 'allow', kind: 'write', resource: filePath, message: '' };
485
+ }
486
+
487
+ if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
488
+ return {
489
+ status: 'deny',
490
+ kind: 'write',
491
+ resource: filePath,
492
+ message: `Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
493
+ };
494
+ }
495
+
496
+ return {
497
+ status: 'ask',
498
+ kind: 'write',
499
+ resource: filePath,
500
+ message: `Sandbox: write access requires approval for "${filePath}" (not in filesystem.allowWrite).`,
501
+ };
502
+ }
503
+
504
+ function evaluateDomainPermission(
505
+ domain: string,
506
+ config: SandboxConfig,
507
+ ): SandboxPermissionDecision {
508
+ if (config.network.allowNetwork || domainMatchesAny(domain, config.network.allowedDomains)) {
509
+ return { status: 'allow', kind: 'domain', resource: domain, message: '' };
510
+ }
511
+
512
+ if (domainMatchesAny(domain, config.network.deniedDomains)) {
513
+ return {
514
+ status: 'deny',
515
+ kind: 'domain',
516
+ resource: domain,
517
+ message: `Sandbox: network access denied for "${domain}" (is blocked by network.deniedDomains).`,
518
+ };
519
+ }
520
+
521
+ return {
522
+ status: 'ask',
523
+ kind: 'domain',
524
+ resource: domain,
525
+ message: `Sandbox: network access requires approval for "${domain}" (not in network.allowedDomains).`,
526
+ };
527
+ }
528
+
437
529
  function landstripVersion(): string | null {
438
530
  const result = spawnSync(binaryPath(), ['--version'], { encoding: 'utf-8' });
439
531
  if (result.status !== 0) return null;
@@ -577,7 +669,7 @@ function writePolicyFile(
577
669
  baseDirectory: string,
578
670
  proxyPort: number | null,
579
671
  ): { dir: string; path: string } {
580
- const dir = mkdtempSync(join(tmpdir(), 'opencode-landstrip-XXXXXX'));
672
+ const dir = mkdtempSync(join(tmpdir(), 'opencode-landstrip-'));
581
673
  const path = join(dir, 'policy.json');
582
674
  writeFileSync(
583
675
  path,
@@ -666,8 +758,11 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
666
758
  buffered = Buffer.concat([buffered, chunk]);
667
759
  const headerEnd = buffered.indexOf('\r\n\r\n');
668
760
  if (headerEnd === -1) {
669
- if (buffered.length > 65536)
761
+ if (buffered.length > 65536) {
762
+ client.removeAllListeners('data');
763
+ client.pause();
670
764
  denyProxyRequest(client, '431 Request Header Fields Too Large');
765
+ }
671
766
  return;
672
767
  }
673
768
 
@@ -758,6 +853,43 @@ function landstripDescription(description: string): string {
758
853
  return description.endsWith(' (landstrip)') ? description : `${description} (landstrip)`;
759
854
  }
760
855
 
856
+ function splitShellQuotedArgs(command: string): string[] {
857
+ const args: string[] = [];
858
+ let i = 0;
859
+ while (i < command.length) {
860
+ while (i < command.length && command[i] === ' ') i++;
861
+ if (i >= command.length) break;
862
+ if (command[i] === "'") {
863
+ i++;
864
+ let arg = '';
865
+ while (i < command.length && command[i] !== "'") {
866
+ arg += command[i];
867
+ i++;
868
+ }
869
+ if (i < command.length) i++;
870
+ args.push(arg);
871
+ } else {
872
+ let arg = '';
873
+ while (i < command.length && command[i] !== ' ') {
874
+ arg += command[i];
875
+ i++;
876
+ }
877
+ args.push(arg);
878
+ }
879
+ }
880
+ return args;
881
+ }
882
+
883
+ function extractOriginalCommand(wrappedCommand: string): string | null {
884
+ const args = splitShellQuotedArgs(wrappedCommand);
885
+ const pIdx = args.indexOf('-p');
886
+ if (pIdx === -1 || pIdx + 3 >= args.length) return null;
887
+ const flagIdx = pIdx + 3;
888
+ const flag = args[flagIdx];
889
+ if (flag !== '-lc' && flag !== '-c') return null;
890
+ return args.slice(flagIdx + 1).join(' ');
891
+ }
892
+
761
893
  function getToolPath(args: Record<string, unknown>): string | undefined {
762
894
  const filePath = args.filePath ?? args.path;
763
895
  return typeof filePath === 'string' ? filePath : undefined;
@@ -789,71 +921,16 @@ function errorWithConfigPaths(baseDirectory: string, message: string): Error {
789
921
  return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
790
922
  }
791
923
 
792
- function assertReadAllowed(
793
- path: string,
794
- config: SandboxConfig,
795
- baseDirectory: string,
796
- effectiveAllowRead: string[],
797
- ): void {
798
- const filePath = canonicalizePath(path, baseDirectory);
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
- }
808
-
809
- throw errorWithConfigPaths(
810
- baseDirectory,
811
- `Sandbox: read access denied for "${filePath}" (not in filesystem.allowRead).`,
812
- );
813
- }
814
-
815
- function assertWriteAllowed(
816
- path: string,
817
- config: SandboxConfig,
818
- baseDirectory: string,
819
- effectiveAllowWrite: string[],
820
- ): void {
821
- const filePath = canonicalizePath(path, baseDirectory);
822
-
823
- if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) return;
824
-
825
- if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
826
- throw errorWithConfigPaths(
827
- baseDirectory,
828
- `Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
829
- );
830
- }
831
-
832
- throw errorWithConfigPaths(
833
- baseDirectory,
834
- `Sandbox: write access denied for "${filePath}" (not in filesystem.allowWrite).`,
835
- );
836
- }
837
-
838
- function assertApplyPatchAllowed(
839
- args: Record<string, unknown>,
840
- config: SandboxConfig,
841
- baseDirectory: string,
842
- effectiveAllowWrite: string[],
843
- ): void {
844
- if (typeof args.patchText !== 'string') return;
845
- for (const path of extractPatchPaths(args.patchText))
846
- assertWriteAllowed(path, config, baseDirectory, effectiveAllowWrite);
847
- }
848
-
849
924
  const plugin: Plugin = async ({ client, directory }: PluginInput, options?: PluginOptions) => {
850
925
  const optionOverrides = normalizeOptions(options);
851
926
  const activeBash = new Map<string, BashSandboxState>();
852
927
  const notified = new Set<string>();
853
- const sessionAllowedDomains: string[] = [];
854
928
  const sessionAllowedReadPaths: string[] = [];
855
929
  const sessionAllowedWritePaths: string[] = [];
930
+ const sessionAllowedDomains: string[] = [];
931
+ const callAllowances = new Set<string>();
856
932
  let enabledNotified = false;
933
+ let sandboxDisabled = false;
857
934
  let configuredShell: string | undefined;
858
935
  let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
859
936
 
@@ -865,8 +942,80 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
865
942
  return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
866
943
  }
867
944
 
945
+ function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
946
+ return [...config.network.allowedDomains, ...sessionAllowedDomains];
947
+ }
948
+
949
+ function allowanceKey(callID: string, kind: SandboxPermissionKind, resource: string): string {
950
+ return `${callID}:${kind}:${resource}`;
951
+ }
952
+
953
+ function rememberCallAllowance(
954
+ callID: string | undefined,
955
+ decision: SandboxPermissionDecision,
956
+ ): void {
957
+ if (!callID || decision.status === 'deny') return;
958
+ callAllowances.add(allowanceKey(callID, decision.kind, decision.resource));
959
+ }
960
+
961
+ function hasCallAllowance(callID: string, decision: SandboxPermissionDecision): boolean {
962
+ return callAllowances.has(allowanceKey(callID, decision.kind, decision.resource));
963
+ }
964
+
965
+ function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
966
+ if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
967
+ throw errorWithConfigPaths(directory, decision.message);
968
+ }
969
+
970
+ function pushCommandText(
971
+ input: { sessionID: string },
972
+ output: { parts: unknown[] },
973
+ text: string,
974
+ ): void {
975
+ output.parts.push({
976
+ type: 'text',
977
+ text,
978
+ id: '',
979
+ sessionID: input.sessionID,
980
+ messageID: '',
981
+ });
982
+ }
983
+
984
+ function sandboxSummary(config: SandboxConfig): string {
985
+ const { globalPath, projectPath } = getConfigPaths(directory);
986
+ const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
987
+ const allowed = getEffectiveAllowedDomains(config).join(', ') || '(none)';
988
+ const denied = config.network.deniedDomains.join(', ') || '(none)';
989
+ const denyRead = config.filesystem.denyRead.join(', ') || '(none)';
990
+ const allowRead = getEffectiveAllowRead(config).join(', ') || '(none)';
991
+ const allowWrite = getEffectiveAllowWrite(config).join(', ') || '(none)';
992
+ const denyWrite = config.filesystem.denyWrite.join(', ') || '(none)';
993
+
994
+ return [
995
+ '# Sandbox Configuration',
996
+ '',
997
+ `Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
998
+ `landstrip: ${binaryPath()}`,
999
+ '',
1000
+ 'Config files:',
1001
+ `- project: ${projectPath}`,
1002
+ `- global: ${globalPath}`,
1003
+ '',
1004
+ `Network (${networkMode}):`,
1005
+ `- allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
1006
+ `- allowed: ${allowed}`,
1007
+ `- denied: ${denied}`,
1008
+ '',
1009
+ 'Filesystem:',
1010
+ `- deny read: ${denyRead}`,
1011
+ `- allow read: ${allowRead}`,
1012
+ `- allow write: ${allowWrite}`,
1013
+ `- deny write: ${denyWrite}`,
1014
+ ].join('\n');
1015
+ }
1016
+
868
1017
  client.app
869
- .log({
1018
+ ?.log?.({
870
1019
  body: {
871
1020
  service: 'opencode-landstrip',
872
1021
  level: 'info',
@@ -874,10 +1023,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
874
1023
  },
875
1024
  query: { directory },
876
1025
  })
877
- .catch(() => undefined);
1026
+ ?.catch?.(() => undefined);
878
1027
 
879
1028
  client.tui
880
- .showToast({
1029
+ ?.showToast?.({
881
1030
  body: {
882
1031
  title: 'Sandbox',
883
1032
  message: `Loaded for ${directory}`,
@@ -885,29 +1034,41 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
885
1034
  duration: 5000,
886
1035
  },
887
1036
  })
888
- .catch(() => undefined);
1037
+ ?.catch?.(() => undefined);
1038
+
1039
+ const notifyGate = new Map<string, Promise<void>>();
889
1040
 
890
1041
  async function notifyOnce(key: string, message: string, variant: ToastVariant): Promise<void> {
891
1042
  if (notified.has(key)) return;
892
- notified.add(key);
893
-
894
- await client.tui
895
- .showToast({
896
- body: { title: 'opencode-landstrip', message, variant },
897
- query: { directory },
898
- })
899
- .catch(() => undefined);
900
-
901
- await client.app
902
- .log({
903
- body: {
904
- service: 'opencode-landstrip',
905
- level: variant === 'error' ? 'error' : variant === 'warning' ? 'warn' : 'info',
906
- message,
907
- },
908
- query: { directory },
909
- })
910
- .catch(() => undefined);
1043
+ const pending = notifyGate.get(key);
1044
+ if (pending) return pending;
1045
+
1046
+ const promise = (async () => {
1047
+ notified.add(key);
1048
+
1049
+ await client.tui
1050
+ ?.showToast?.({
1051
+ body: { title: 'opencode-landstrip', message, variant },
1052
+ query: { directory },
1053
+ })
1054
+ ?.catch?.(() => undefined);
1055
+
1056
+ await client.app
1057
+ ?.log?.({
1058
+ body: {
1059
+ service: 'opencode-landstrip',
1060
+ level: variant === 'error' ? 'error' : variant === 'warning' ? 'warn' : 'info',
1061
+ message,
1062
+ },
1063
+ query: { directory },
1064
+ })
1065
+ ?.catch?.(() => undefined);
1066
+
1067
+ notifyGate.delete(key);
1068
+ })();
1069
+
1070
+ notifyGate.set(key, promise);
1071
+ return promise;
911
1072
  }
912
1073
 
913
1074
  function checkLandstrip(): typeof landstripCheck {
@@ -943,6 +1104,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
943
1104
  }
944
1105
 
945
1106
  async function activeConfig(): Promise<SandboxConfig | null> {
1107
+ if (sandboxDisabled) return null;
1108
+
946
1109
  const config = loadConfig(directory, optionOverrides);
947
1110
  if (!config.enabled) return null;
948
1111
 
@@ -1014,40 +1177,58 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1014
1177
  await cleanupBash(callID);
1015
1178
  }
1016
1179
 
1017
- if (isGeneratedWrappedCommand(args.command)) {
1018
- if (typeof args.description === 'string')
1019
- args.description = landstripDescription(args.description);
1020
- return;
1180
+ if (isGeneratedWrappedCommand(args.command as string)) {
1181
+ const policyMatch = (args.command as string).match(/\s'-p'\s+'([^']+)'/);
1182
+ if (policyMatch && existsSync(policyMatch[1])) {
1183
+ if (typeof args.description === 'string')
1184
+ args.description = landstripDescription(args.description);
1185
+ return;
1186
+ }
1187
+ if (activeBash.has(callID)) await cleanupBash(callID);
1188
+ const original = extractOriginalCommand(args.command as string);
1189
+ if (original) {
1190
+ args.command = original;
1191
+ }
1021
1192
  }
1022
1193
 
1023
1194
  const allowNetwork = config.network.allowNetwork;
1195
+ const callAllowedDomains: string[] = [];
1196
+ const effectiveConfig = {
1197
+ ...config,
1198
+ network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1199
+ };
1024
1200
 
1025
1201
  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
- );
1202
+ for (const domain of extractDomainsFromCommand(args.command as string)) {
1203
+ const decision = evaluateDomainPermission(domain, effectiveConfig);
1204
+ if (decision.status === 'allow') continue;
1205
+ if (decision.status === 'ask' && hasCallAllowance(callID, decision)) {
1206
+ callAllowedDomains.push(domain);
1207
+ continue;
1208
+ }
1209
+ throw errorWithConfigPaths(directory, decision.message);
1036
1210
  }
1037
1211
  }
1038
1212
 
1039
- const proxy = allowNetwork ? null : await startProxy(config);
1213
+ if (callAllowedDomains.length > 0) {
1214
+ effectiveConfig.network = {
1215
+ ...effectiveConfig.network,
1216
+ allowedDomains: [...effectiveConfig.network.allowedDomains, ...callAllowedDomains],
1217
+ };
1218
+ }
1219
+
1220
+ const proxy = allowNetwork ? null : await startProxy(effectiveConfig);
1040
1221
  const proxyPort = proxy ? proxy.port : null;
1041
1222
  let policy: { dir: string; path: string };
1042
1223
 
1043
1224
  try {
1044
- policy = writePolicyFile(config, directory, proxyPort);
1225
+ policy = writePolicyFile(effectiveConfig, directory, proxyPort);
1045
1226
  } catch (error) {
1046
1227
  if (proxy) await proxy.stop().catch(() => undefined);
1047
1228
  throw error;
1048
1229
  }
1049
1230
 
1050
- const originalCommand = args.command;
1231
+ const originalCommand = args.command as string;
1051
1232
  const wrappedCommand = buildWrappedCommand(
1052
1233
  policy.path,
1053
1234
  configuredShell ?? process.env.SHELL ?? '/bin/sh',
@@ -1067,45 +1248,85 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1067
1248
  args.description = landstripDescription(args.description);
1068
1249
  }
1069
1250
 
1070
- function buildConfigSummary(config: SandboxConfig): string {
1071
- const { globalPath, projectPath } = getConfigPaths(directory);
1072
- const check = checkLandstrip();
1073
- const version = check?.ok === true ? check.version : 'unknown';
1074
- const status = config.enabled && check?.ok === true ? 'enabled' : 'disabled';
1075
-
1076
- const lines = [
1077
- 'Sandbox Configuration',
1078
- ` Status: ${status}`,
1079
- ` Project config: ${projectPath}`,
1080
- ` Global config: ${globalPath}`,
1081
- ` landstrip: ${binaryPath()} (v${version})`,
1082
- '',
1083
- ` Allow network: ${config.network.allowNetwork}`,
1084
- '',
1085
- 'Network (bash commands go through HTTP proxy):',
1086
- ` Allow local binding: ${config.network.allowLocalBinding}`,
1087
- ` Allow all Unix sockets: ${config.network.allowAllUnixSockets}`,
1088
- ...(config.network.allowUnixSockets.length > 0
1089
- ? [` Allow Unix sockets: ${config.network.allowUnixSockets.join(', ')}`]
1090
- : []),
1091
- ` Allowed domains: ${config.network.allowedDomains.join(', ') || '(none)'}`,
1092
- ` Denied domains: ${config.network.deniedDomains.join(', ') || '(none)'}`,
1093
- '',
1094
- 'Filesystem (bash + read/write/edit tools):',
1095
- ` Deny Read: ${config.filesystem.denyRead.join(', ') || '(none)'}`,
1096
- ` Allow Read: ${config.filesystem.allowRead.join(', ') || '(none)'}`,
1097
- ` Allow Write: ${config.filesystem.allowWrite.join(', ') || '(none)'}`,
1098
- ` Deny Write: ${config.filesystem.denyWrite.join(', ') || '(none)'}`,
1099
- ];
1100
-
1101
- return lines.join('\n');
1102
- }
1103
-
1104
1251
  const hooks: Hooks = {
1105
1252
  config: async (config) => {
1106
1253
  configuredShell = configuredShellPath(config);
1107
1254
  },
1108
1255
 
1256
+ 'permission.ask': async (input, output) => {
1257
+ const config = await activeConfig();
1258
+ if (!config) return;
1259
+
1260
+ const request = input as Record<string, unknown>;
1261
+ const permission =
1262
+ typeof request.type === 'string'
1263
+ ? request.type
1264
+ : typeof request.permission === 'string'
1265
+ ? request.permission
1266
+ : typeof request.action === 'string'
1267
+ ? request.action
1268
+ : '';
1269
+ const metadata = isRecord(request.metadata) ? request.metadata : {};
1270
+ const tool = isRecord(request.tool) ? request.tool : undefined;
1271
+ const callID =
1272
+ typeof request.callID === 'string'
1273
+ ? request.callID
1274
+ : typeof tool?.callID === 'string'
1275
+ ? tool.callID
1276
+ : undefined;
1277
+ const patterns = Array.isArray(request.patterns)
1278
+ ? request.patterns.filter((item): item is string => typeof item === 'string')
1279
+ : typeof request.pattern === 'string'
1280
+ ? [request.pattern]
1281
+ : Array.isArray(request.resources)
1282
+ ? request.resources.filter((item): item is string => typeof item === 'string')
1283
+ : [];
1284
+
1285
+ const decisions: SandboxPermissionDecision[] = [];
1286
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1287
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1288
+
1289
+ if (permission === 'read') {
1290
+ for (const pattern of patterns) {
1291
+ decisions.push(evaluateReadPermission(pattern, config, directory, effectiveAllowRead));
1292
+ }
1293
+ }
1294
+
1295
+ if (permission === 'glob' || permission === 'grep' || permission === 'list') {
1296
+ const searchPath = typeof metadata.path === 'string' ? metadata.path : '.';
1297
+ decisions.push(evaluateReadPermission(searchPath, config, directory, effectiveAllowRead));
1298
+ }
1299
+
1300
+ if (permission === 'edit') {
1301
+ const filepath =
1302
+ typeof metadata.filepath === 'string'
1303
+ ? metadata.filepath
1304
+ : patterns.length === 1
1305
+ ? patterns[0]
1306
+ : undefined;
1307
+ if (filepath) {
1308
+ decisions.push(evaluateWritePermission(filepath, config, directory, effectiveAllowWrite));
1309
+ }
1310
+ }
1311
+
1312
+ if (permission === 'bash') {
1313
+ const command = typeof metadata.command === 'string' ? metadata.command : patterns[0];
1314
+ if (typeof command === 'string' && !config.network.allowNetwork) {
1315
+ for (const domain of extractDomainsFromCommand(command)) {
1316
+ decisions.push(evaluateDomainPermission(domain, config));
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ const decision =
1322
+ decisions.find((item) => item.status === 'deny') ??
1323
+ decisions.find((item) => item.status === 'ask');
1324
+ if (!decision) return;
1325
+
1326
+ output.status = decision.status;
1327
+ rememberCallAllowance(callID, decision);
1328
+ },
1329
+
1109
1330
  'tool.execute.before': async (input, output) => {
1110
1331
  if (!isRecord(output.args)) return;
1111
1332
 
@@ -1122,23 +1343,40 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1122
1343
 
1123
1344
  if (input.tool === 'read') {
1124
1345
  const path = getToolPath(output.args);
1125
- if (path) assertReadAllowed(path, config, directory, effectiveAllowRead);
1346
+ if (path)
1347
+ enforcePermission(
1348
+ input.callID,
1349
+ evaluateReadPermission(path, config, directory, effectiveAllowRead),
1350
+ );
1126
1351
  return;
1127
1352
  }
1128
1353
 
1129
1354
  if (input.tool === 'glob' || input.tool === 'grep' || input.tool === 'list') {
1130
- assertReadAllowed(getSearchPath(output.args), config, directory, effectiveAllowRead);
1355
+ enforcePermission(
1356
+ input.callID,
1357
+ evaluateReadPermission(getSearchPath(output.args), config, directory, effectiveAllowRead),
1358
+ );
1131
1359
  return;
1132
1360
  }
1133
1361
 
1134
1362
  if (input.tool === 'write' || input.tool === 'edit') {
1135
1363
  const path = getToolPath(output.args);
1136
- if (path) assertWriteAllowed(path, config, directory, effectiveAllowWrite);
1364
+ if (path)
1365
+ enforcePermission(
1366
+ input.callID,
1367
+ evaluateWritePermission(path, config, directory, effectiveAllowWrite),
1368
+ );
1137
1369
  return;
1138
1370
  }
1139
1371
 
1140
- if (input.tool === 'apply_patch')
1141
- assertApplyPatchAllowed(output.args, config, directory, effectiveAllowWrite);
1372
+ if (input.tool === 'apply_patch' && typeof output.args.patchText === 'string') {
1373
+ for (const path of extractPatchPaths(output.args.patchText)) {
1374
+ enforcePermission(
1375
+ input.callID,
1376
+ evaluateWritePermission(path, config, directory, effectiveAllowWrite),
1377
+ );
1378
+ }
1379
+ }
1142
1380
  },
1143
1381
 
1144
1382
  'shell.env': async (input, output) => {
@@ -1164,13 +1402,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1164
1402
  if (errors.length > 0) {
1165
1403
  const message = formatLandstripErrors(errors);
1166
1404
  await client.tui
1167
- .showToast({
1405
+ ?.showToast?.({
1168
1406
  body: { title: 'opencode-landstrip', message, variant: 'error' },
1169
1407
  query: { directory },
1170
1408
  })
1171
- .catch(() => undefined);
1409
+ ?.catch?.(() => undefined);
1172
1410
  await client.app
1173
- .log({
1411
+ ?.log?.({
1174
1412
  body: {
1175
1413
  service: 'opencode-landstrip',
1176
1414
  level: 'error',
@@ -1178,61 +1416,102 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1178
1416
  },
1179
1417
  query: { directory },
1180
1418
  })
1181
- .catch(() => undefined);
1419
+ ?.catch?.(() => undefined);
1182
1420
  }
1183
1421
 
1184
1422
  const blockedPath = extractBlockedWritePath(outputText, directory, state.originalCommand);
1185
1423
  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
- }
1424
+ await notifyOnce(
1425
+ `blocked:${blockedPath}`,
1426
+ `Sandbox blocked access to "${blockedPath}". Approve the related OpenCode permission prompt and retry if needed.`,
1427
+ 'warning',
1428
+ );
1199
1429
  }
1200
1430
 
1201
1431
  await cleanupBash(input.callID);
1202
1432
  },
1203
1433
 
1204
1434
  'command.execute.before': async (input, output) => {
1205
- if (input.command === 'sandbox' || input.command === '/sandbox') {
1435
+ if (input.command.trim() === '/sandbox') {
1206
1436
  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);
1437
+ pushCommandText(input, output, sandboxSummary(config));
1214
1438
  return;
1215
1439
  }
1216
1440
 
1217
- // Check domain in user shell commands (commands starting with !)
1441
+ if (input.command.trim() === '/sandbox-disable') {
1442
+ if (sandboxDisabled) {
1443
+ pushCommandText(
1444
+ input,
1445
+ output,
1446
+ 'Sandbox is already disabled. Use /sandbox-enable to re-enable.',
1447
+ );
1448
+ return;
1449
+ }
1450
+ sandboxDisabled = true;
1451
+ pushCommandText(
1452
+ input,
1453
+ output,
1454
+ 'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
1455
+ );
1456
+ return;
1457
+ }
1458
+
1459
+ if (input.command.trim() === '/sandbox-enable') {
1460
+ if (!sandboxDisabled) {
1461
+ pushCommandText(
1462
+ input,
1463
+ output,
1464
+ 'Sandbox is already enabled. Use /sandbox-disable to pause.',
1465
+ );
1466
+ return;
1467
+ }
1468
+ sandboxDisabled = false;
1469
+ pushCommandText(input, output, 'Sandbox re-enabled.');
1470
+ return;
1471
+ }
1472
+
1473
+ // Check domain and filesystem in user shell commands (commands starting with !)
1218
1474
  if (input.command.startsWith('!')) {
1219
1475
  const shellCommand = input.command.slice(1).trim();
1220
1476
  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
- });
1477
+ if (!config) return;
1478
+
1479
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1480
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1481
+
1482
+ for (const path of extractCandidatePaths(shellCommand)) {
1483
+ const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
1484
+ if (readDecision.status === 'deny') {
1485
+ throw errorWithConfigPaths(directory, readDecision.message);
1486
+ }
1487
+
1488
+ const writeDecision = evaluateWritePermission(
1489
+ path,
1490
+ config,
1491
+ directory,
1492
+ effectiveAllowWrite,
1493
+ );
1494
+ if (writeDecision.status === 'deny') {
1495
+ throw errorWithConfigPaths(directory, writeDecision.message);
1496
+ }
1497
+ }
1498
+
1499
+ if (!config.network.allowNetwork) {
1500
+ const effectiveConfig = {
1501
+ ...config,
1502
+ network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1503
+ };
1504
+ const blockedDomain = firstBlockedDomain(shellCommand, effectiveConfig);
1505
+ if (blockedDomain) {
1506
+ const reason =
1507
+ blockedDomain.reason === 'deniedDomains'
1508
+ ? 'is blocked by network.deniedDomains'
1509
+ : 'is not in network.allowedDomains';
1510
+ throw errorWithConfigPaths(
1511
+ directory,
1512
+ `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
1513
+ );
1514
+ }
1236
1515
  }
1237
1516
  }
1238
1517
  },
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.9",
3
+ "version": "0.3.11",
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
  ],
@@ -50,17 +51,17 @@
50
51
  "@jarkkojs/landstrip": "^0.11.0"
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.3",
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.3"
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.3"
72
73
  }
73
74
  }
package/tui.ts CHANGED
@@ -3,64 +3,269 @@
3
3
 
4
4
  import type { TuiPlugin } from '@opencode-ai/plugin/tui';
5
5
 
6
- const tui: TuiPlugin = async (api) => {
6
+ import { binaryPath } from '@jarkkojs/landstrip';
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ interface SandboxFilesystemConfig {
13
+ denyRead: string[];
14
+ allowRead: string[];
15
+ allowWrite: string[];
16
+ denyWrite: string[];
17
+ }
18
+
19
+ interface SandboxNetworkConfig {
20
+ allowNetwork: boolean;
21
+ allowLocalBinding: boolean;
22
+ allowAllUnixSockets: boolean;
23
+ allowUnixSockets: string[];
24
+ allowedDomains: string[];
25
+ deniedDomains: string[];
26
+ }
27
+
28
+ interface SandboxConfig {
29
+ enabled: boolean;
30
+ network: SandboxNetworkConfig;
31
+ filesystem: SandboxFilesystemConfig;
32
+ }
33
+
34
+ interface SandboxConfigOverrides {
35
+ enabled?: boolean;
36
+ network?: Partial<SandboxNetworkConfig>;
37
+ filesystem?: Partial<SandboxFilesystemConfig>;
38
+ }
39
+
40
+ const DEFAULT_CONFIG: SandboxConfig = {
41
+ enabled: true,
42
+ network: {
43
+ allowNetwork: false,
44
+ allowLocalBinding: false,
45
+ allowAllUnixSockets: false,
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
+ ],
62
+ deniedDomains: [],
63
+ },
64
+ filesystem: {
65
+ 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'],
77
+ },
78
+ };
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
82
+ }
83
+
84
+ function stringArray(value: unknown): string[] | undefined {
85
+ if (!Array.isArray(value)) return undefined;
86
+ return value.every((item) => typeof item === 'string') ? [...value] : undefined;
87
+ }
88
+
89
+ function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
90
+ if (!isRecord(value)) return undefined;
91
+
92
+ const config: Partial<SandboxNetworkConfig> = {};
93
+ if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
94
+ if (typeof value.allowLocalBinding === 'boolean')
95
+ config.allowLocalBinding = value.allowLocalBinding;
96
+ if (typeof value.allowAllUnixSockets === 'boolean')
97
+ config.allowAllUnixSockets = value.allowAllUnixSockets;
98
+
99
+ const allowUnixSockets = stringArray(value.allowUnixSockets);
100
+ if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
101
+
102
+ const allowedDomains = stringArray(value.allowedDomains);
103
+ if (allowedDomains) config.allowedDomains = allowedDomains;
104
+
105
+ const deniedDomains = stringArray(value.deniedDomains);
106
+ if (deniedDomains) config.deniedDomains = deniedDomains;
107
+
108
+ return config;
109
+ }
110
+
111
+ function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
112
+ if (!isRecord(value)) return undefined;
113
+
114
+ const config: Partial<SandboxFilesystemConfig> = {};
115
+ const denyRead = stringArray(value.denyRead);
116
+ if (denyRead) config.denyRead = denyRead;
117
+
118
+ const allowRead = stringArray(value.allowRead);
119
+ if (allowRead) config.allowRead = allowRead;
120
+
121
+ const allowWrite = stringArray(value.allowWrite);
122
+ if (allowWrite) config.allowWrite = allowWrite;
123
+
124
+ const denyWrite = stringArray(value.denyWrite);
125
+ if (denyWrite) config.denyWrite = denyWrite;
126
+
127
+ return config;
128
+ }
129
+
130
+ function normalizeConfig(value: unknown): SandboxConfigOverrides {
131
+ if (!isRecord(value)) return {};
132
+
133
+ const config: SandboxConfigOverrides = {};
134
+ if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
135
+
136
+ const network = normalizeNetworkConfig(value.network);
137
+ if (network) config.network = network;
138
+
139
+ const filesystem = normalizeFilesystemConfig(value.filesystem);
140
+ if (filesystem) config.filesystem = filesystem;
141
+
142
+ return config;
143
+ }
144
+
145
+ function normalizeOptions(options: unknown): SandboxConfigOverrides {
146
+ if (!isRecord(options)) return {};
147
+ return normalizeConfig(isRecord(options.config) ? options.config : options);
148
+ }
149
+
150
+ function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
151
+ return {
152
+ enabled: overrides.enabled ?? base.enabled,
153
+ network: {
154
+ ...base.network,
155
+ ...overrides.network,
156
+ },
157
+ filesystem: {
158
+ ...base.filesystem,
159
+ ...overrides.filesystem,
160
+ },
161
+ };
162
+ }
163
+
164
+ function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
165
+ return {
166
+ globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
167
+ projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
168
+ };
169
+ }
170
+
171
+ function readConfigFile(configPath: string): SandboxConfigOverrides {
172
+ if (!existsSync(configPath)) return {};
173
+
7
174
  try {
8
- api.keymap.registerLayer({
9
- commands: [
10
- {
11
- name: 'sandbox',
12
- title: 'Sandbox',
13
- description: 'Show sandbox configuration',
14
- category: 'plugin',
15
- keybind: 'ctrl+x b',
16
- suggested: true,
17
- slash: { name: 'sandbox' },
18
- run: async () => {
19
- await api.client.tui.executeCommand({ command: 'sandbox' });
20
- return true;
21
- },
22
- },
23
- ],
24
- });
25
-
26
- if (api.command) {
27
- api.command.register(() => [
28
- {
29
- title: 'Sandbox',
30
- value: 'sandbox',
31
- description: 'Show sandbox configuration',
32
- category: 'plugin',
33
- suggested: true,
34
- slash: { name: 'sandbox' },
35
- onSelect: async () => {
36
- await api.client.tui.executeCommand({ command: 'sandbox' });
37
- },
38
- },
39
- ]);
40
- }
41
-
42
- const client = api.client;
43
- if (client?.tui?.showToast) {
44
- client.tui
45
- .showToast({
46
- title: 'Sandbox',
47
- message: '/sandbox command registered',
48
- variant: 'info',
49
- })
50
- .catch(() => undefined);
51
- }
52
- } catch (err) {
53
- const client = api.client;
54
- if (client?.tui?.showToast) {
55
- client.tui
56
- .showToast({
57
- title: 'Sandbox error',
58
- message: err instanceof Error ? err.message : String(err),
59
- variant: 'error',
60
- })
61
- .catch(() => undefined);
62
- }
175
+ return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
176
+ } catch {
177
+ return {};
63
178
  }
179
+ }
180
+
181
+ function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
182
+ const { globalPath, projectPath } = getConfigPaths(baseDirectory);
183
+ return deepMerge(
184
+ deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
185
+ optionOverrides,
186
+ );
187
+ }
188
+
189
+ function list(values: string[]): string {
190
+ return values.join(', ') || '(none)';
191
+ }
192
+
193
+ function configPathLine(label: string, filePath: string): string {
194
+ return `${label}: ${filePath} ${existsSync(filePath) ? '(found)' : '(missing)'}`;
195
+ }
196
+
197
+ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOverrides): string {
198
+ const config = loadConfig(baseDirectory, optionOverrides);
199
+ const { globalPath, projectPath } = getConfigPaths(baseDirectory);
200
+ const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
201
+
202
+ return [
203
+ `Status: ${config.enabled ? 'active' : 'disabled by config'}`,
204
+ `landstrip: ${binaryPath()}`,
205
+ '',
206
+ 'Config files',
207
+ configPathLine('project', projectPath),
208
+ configPathLine('global', globalPath),
209
+ '',
210
+ `Network: ${networkMode}`,
211
+ `allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
212
+ `allowed: ${list(config.network.allowedDomains)}`,
213
+ `denied: ${list(config.network.deniedDomains)}`,
214
+ `unix sockets: ${config.network.allowAllUnixSockets ? 'all' : list(config.network.allowUnixSockets)}`,
215
+ '',
216
+ 'Filesystem',
217
+ `deny read: ${list(config.filesystem.denyRead)}`,
218
+ `allow read: ${list(config.filesystem.allowRead)}`,
219
+ `allow write: ${list(config.filesystem.allowWrite)}`,
220
+ `deny write: ${list(config.filesystem.denyWrite)}`,
221
+ '',
222
+ 'esc or any key to close',
223
+ ].join('\n');
224
+ }
225
+
226
+ const tui: TuiPlugin = async (api, options) => {
227
+ const showSandbox = () => {
228
+ const directory = api.state.path.directory || process.cwd();
229
+ const message = sandboxSummary(directory, normalizeOptions(options));
230
+
231
+ api.ui.dialog.replace(
232
+ () =>
233
+ api.ui.DialogAlert({
234
+ title: 'Sandbox Configuration',
235
+ message,
236
+ onConfirm: () => api.ui.dialog.clear(),
237
+ }),
238
+ () => api.ui.dialog.clear(),
239
+ );
240
+ };
241
+
242
+ api.keymap.registerLayer({
243
+ commands: [
244
+ {
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',
250
+ category: 'Sandbox',
251
+ suggested: true,
252
+ slashName: 'sandbox',
253
+ run: showSandbox,
254
+ },
255
+ ],
256
+ });
257
+
258
+ api.command?.register(() => [
259
+ {
260
+ title: 'Sandbox',
261
+ value: 'landstrip.sandbox.show',
262
+ description: 'Show sandbox configuration',
263
+ category: 'Sandbox',
264
+ suggested: true,
265
+ slash: { name: 'sandbox' },
266
+ onSelect: showSandbox,
267
+ },
268
+ ]);
64
269
  };
65
270
 
66
271
  export { tui };