opencode-landstrip 0.3.9 → 0.3.10

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/README.md +17 -2
  2. package/index.ts +215 -95
  3. package/package.json +1 -1
  4. package/tui.ts +261 -56
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
@@ -310,7 +310,7 @@ function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory:
310
310
  }
311
311
 
312
312
  function extractDomainsFromCommand(command: string): string[] {
313
- const urlRegex = /https?:\/\/([^\s/:?#]+)(?::\d+)?(?:[/?#]|\s|$)/g;
313
+ const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
314
314
  const domains = new Set<string>();
315
315
  let match: RegExpExecArray | null;
316
316
 
@@ -577,7 +577,7 @@ function writePolicyFile(
577
577
  baseDirectory: string,
578
578
  proxyPort: number | null,
579
579
  ): { dir: string; path: string } {
580
- const dir = mkdtempSync(join(tmpdir(), 'opencode-landstrip-XXXXXX'));
580
+ const dir = mkdtempSync(join(tmpdir(), 'opencode-landstrip-'));
581
581
  const path = join(dir, 'policy.json');
582
582
  writeFileSync(
583
583
  path,
@@ -666,8 +666,11 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
666
666
  buffered = Buffer.concat([buffered, chunk]);
667
667
  const headerEnd = buffered.indexOf('\r\n\r\n');
668
668
  if (headerEnd === -1) {
669
- if (buffered.length > 65536)
669
+ if (buffered.length > 65536) {
670
+ client.removeAllListeners('data');
671
+ client.pause();
670
672
  denyProxyRequest(client, '431 Request Header Fields Too Large');
673
+ }
671
674
  return;
672
675
  }
673
676
 
@@ -758,6 +761,43 @@ function landstripDescription(description: string): string {
758
761
  return description.endsWith(' (landstrip)') ? description : `${description} (landstrip)`;
759
762
  }
760
763
 
764
+ function splitShellQuotedArgs(command: string): string[] {
765
+ const args: string[] = [];
766
+ let i = 0;
767
+ while (i < command.length) {
768
+ while (i < command.length && command[i] === ' ') i++;
769
+ if (i >= command.length) break;
770
+ if (command[i] === "'") {
771
+ i++;
772
+ let arg = '';
773
+ while (i < command.length && command[i] !== "'") {
774
+ arg += command[i];
775
+ i++;
776
+ }
777
+ if (i < command.length) i++;
778
+ args.push(arg);
779
+ } else {
780
+ let arg = '';
781
+ while (i < command.length && command[i] !== ' ') {
782
+ arg += command[i];
783
+ i++;
784
+ }
785
+ args.push(arg);
786
+ }
787
+ }
788
+ return args;
789
+ }
790
+
791
+ function extractOriginalCommand(wrappedCommand: string): string | null {
792
+ const args = splitShellQuotedArgs(wrappedCommand);
793
+ const pIdx = args.indexOf('-p');
794
+ if (pIdx === -1 || pIdx + 3 >= args.length) return null;
795
+ const flagIdx = pIdx + 3;
796
+ const flag = args[flagIdx];
797
+ if (flag !== '-lc' && flag !== '-c') return null;
798
+ return args.slice(flagIdx + 1).join(' ');
799
+ }
800
+
761
801
  function getToolPath(args: Record<string, unknown>): string | undefined {
762
802
  const filePath = args.filePath ?? args.path;
763
803
  return typeof filePath === 'string' ? filePath : undefined;
@@ -850,10 +890,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
850
890
  const optionOverrides = normalizeOptions(options);
851
891
  const activeBash = new Map<string, BashSandboxState>();
852
892
  const notified = new Set<string>();
853
- const sessionAllowedDomains: string[] = [];
854
893
  const sessionAllowedReadPaths: string[] = [];
855
894
  const sessionAllowedWritePaths: string[] = [];
856
895
  let enabledNotified = false;
896
+ let sandboxDisabled = false;
857
897
  let configuredShell: string | undefined;
858
898
  let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
859
899
 
@@ -865,8 +905,55 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
865
905
  return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
866
906
  }
867
907
 
908
+ function pushCommandText(
909
+ input: { sessionID: string },
910
+ output: { parts: unknown[] },
911
+ text: string,
912
+ ): void {
913
+ output.parts.push({
914
+ type: 'text',
915
+ text,
916
+ id: '',
917
+ sessionID: input.sessionID,
918
+ messageID: '',
919
+ });
920
+ }
921
+
922
+ function sandboxSummary(config: SandboxConfig): string {
923
+ const { globalPath, projectPath } = getConfigPaths(directory);
924
+ const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
925
+ const allowed = config.network.allowedDomains.join(', ') || '(none)';
926
+ const denied = config.network.deniedDomains.join(', ') || '(none)';
927
+ const denyRead = config.filesystem.denyRead.join(', ') || '(none)';
928
+ const allowRead = getEffectiveAllowRead(config).join(', ') || '(none)';
929
+ const allowWrite = getEffectiveAllowWrite(config).join(', ') || '(none)';
930
+ const denyWrite = config.filesystem.denyWrite.join(', ') || '(none)';
931
+
932
+ return [
933
+ '# Sandbox Configuration',
934
+ '',
935
+ `Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
936
+ `landstrip: ${binaryPath()}`,
937
+ '',
938
+ 'Config files:',
939
+ `- project: ${projectPath}`,
940
+ `- global: ${globalPath}`,
941
+ '',
942
+ `Network (${networkMode}):`,
943
+ `- allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
944
+ `- allowed: ${allowed}`,
945
+ `- denied: ${denied}`,
946
+ '',
947
+ 'Filesystem:',
948
+ `- deny read: ${denyRead}`,
949
+ `- allow read: ${allowRead}`,
950
+ `- allow write: ${allowWrite}`,
951
+ `- deny write: ${denyWrite}`,
952
+ ].join('\n');
953
+ }
954
+
868
955
  client.app
869
- .log({
956
+ ?.log?.({
870
957
  body: {
871
958
  service: 'opencode-landstrip',
872
959
  level: 'info',
@@ -874,10 +961,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
874
961
  },
875
962
  query: { directory },
876
963
  })
877
- .catch(() => undefined);
964
+ ?.catch?.(() => undefined);
878
965
 
879
966
  client.tui
880
- .showToast({
967
+ ?.showToast?.({
881
968
  body: {
882
969
  title: 'Sandbox',
883
970
  message: `Loaded for ${directory}`,
@@ -885,29 +972,41 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
885
972
  duration: 5000,
886
973
  },
887
974
  })
888
- .catch(() => undefined);
975
+ ?.catch?.(() => undefined);
976
+
977
+ const notifyGate = new Map<string, Promise<void>>();
889
978
 
890
979
  async function notifyOnce(key: string, message: string, variant: ToastVariant): Promise<void> {
891
980
  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);
981
+ const pending = notifyGate.get(key);
982
+ if (pending) return pending;
983
+
984
+ const promise = (async () => {
985
+ notified.add(key);
986
+
987
+ await client.tui
988
+ ?.showToast?.({
989
+ body: { title: 'opencode-landstrip', message, variant },
990
+ query: { directory },
991
+ })
992
+ ?.catch?.(() => undefined);
993
+
994
+ await client.app
995
+ ?.log?.({
996
+ body: {
997
+ service: 'opencode-landstrip',
998
+ level: variant === 'error' ? 'error' : variant === 'warning' ? 'warn' : 'info',
999
+ message,
1000
+ },
1001
+ query: { directory },
1002
+ })
1003
+ ?.catch?.(() => undefined);
1004
+
1005
+ notifyGate.delete(key);
1006
+ })();
1007
+
1008
+ notifyGate.set(key, promise);
1009
+ return promise;
911
1010
  }
912
1011
 
913
1012
  function checkLandstrip(): typeof landstripCheck {
@@ -943,6 +1042,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
943
1042
  }
944
1043
 
945
1044
  async function activeConfig(): Promise<SandboxConfig | null> {
1045
+ if (sandboxDisabled) return null;
1046
+
946
1047
  const config = loadConfig(directory, optionOverrides);
947
1048
  if (!config.enabled) return null;
948
1049
 
@@ -1014,16 +1115,24 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1014
1115
  await cleanupBash(callID);
1015
1116
  }
1016
1117
 
1017
- if (isGeneratedWrappedCommand(args.command)) {
1018
- if (typeof args.description === 'string')
1019
- args.description = landstripDescription(args.description);
1020
- return;
1118
+ if (isGeneratedWrappedCommand(args.command as string)) {
1119
+ const policyMatch = (args.command as string).match(/\s'-p'\s+'([^']+)'/);
1120
+ if (policyMatch && existsSync(policyMatch[1])) {
1121
+ if (typeof args.description === 'string')
1122
+ args.description = landstripDescription(args.description);
1123
+ return;
1124
+ }
1125
+ if (activeBash.has(callID)) await cleanupBash(callID);
1126
+ const original = extractOriginalCommand(args.command as string);
1127
+ if (original) {
1128
+ args.command = original;
1129
+ }
1021
1130
  }
1022
1131
 
1023
1132
  const allowNetwork = config.network.allowNetwork;
1024
1133
 
1025
1134
  if (!allowNetwork) {
1026
- const blockedDomain = firstBlockedDomain(args.command, config);
1135
+ const blockedDomain = firstBlockedDomain(args.command as string, config);
1027
1136
  if (blockedDomain) {
1028
1137
  const reason =
1029
1138
  blockedDomain.reason === 'deniedDomains'
@@ -1047,7 +1156,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1047
1156
  throw error;
1048
1157
  }
1049
1158
 
1050
- const originalCommand = args.command;
1159
+ const originalCommand = args.command as string;
1051
1160
  const wrappedCommand = buildWrappedCommand(
1052
1161
  policy.path,
1053
1162
  configuredShell ?? process.env.SHELL ?? '/bin/sh',
@@ -1067,40 +1176,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1067
1176
  args.description = landstripDescription(args.description);
1068
1177
  }
1069
1178
 
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
1179
  const hooks: Hooks = {
1105
1180
  config: async (config) => {
1106
1181
  configuredShell = configuredShellPath(config);
@@ -1164,13 +1239,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1164
1239
  if (errors.length > 0) {
1165
1240
  const message = formatLandstripErrors(errors);
1166
1241
  await client.tui
1167
- .showToast({
1242
+ ?.showToast?.({
1168
1243
  body: { title: 'opencode-landstrip', message, variant: 'error' },
1169
1244
  query: { directory },
1170
1245
  })
1171
- .catch(() => undefined);
1246
+ ?.catch?.(() => undefined);
1172
1247
  await client.app
1173
- .log({
1248
+ ?.log?.({
1174
1249
  body: {
1175
1250
  service: 'opencode-landstrip',
1176
1251
  level: 'error',
@@ -1178,7 +1253,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1178
1253
  },
1179
1254
  query: { directory },
1180
1255
  })
1181
- .catch(() => undefined);
1256
+ ?.catch?.(() => undefined);
1182
1257
  }
1183
1258
 
1184
1259
  const blockedPath = extractBlockedWritePath(outputText, directory, state.originalCommand);
@@ -1196,43 +1271,88 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1196
1271
  'warning',
1197
1272
  );
1198
1273
  }
1274
+ if (
1275
+ !sessionAllowedReadPaths.includes(blockedPath) &&
1276
+ !matchesPattern(blockedPath, config.filesystem.allowRead, directory) &&
1277
+ !matchesPattern(blockedPath, config.filesystem.denyRead, directory)
1278
+ ) {
1279
+ sessionAllowedReadPaths.push(blockedPath);
1280
+ await notifyOnce(
1281
+ `read-allow:${blockedPath}`,
1282
+ `Read access granted for session: "${blockedPath}"`,
1283
+ 'warning',
1284
+ );
1285
+ }
1199
1286
  }
1200
1287
 
1201
1288
  await cleanupBash(input.callID);
1202
1289
  },
1203
1290
 
1204
1291
  'command.execute.before': async (input, output) => {
1205
- if (input.command === 'sandbox' || input.command === '/sandbox') {
1292
+ if (input.command.trim() === '/sandbox') {
1206
1293
  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);
1294
+ pushCommandText(input, output, sandboxSummary(config));
1214
1295
  return;
1215
1296
  }
1216
1297
 
1217
- // Check domain in user shell commands (commands starting with !)
1298
+ if (input.command.trim() === '/sandbox-disable') {
1299
+ if (sandboxDisabled) {
1300
+ pushCommandText(
1301
+ input,
1302
+ output,
1303
+ 'Sandbox is already disabled. Use /sandbox-enable to re-enable.',
1304
+ );
1305
+ return;
1306
+ }
1307
+ sandboxDisabled = true;
1308
+ pushCommandText(
1309
+ input,
1310
+ output,
1311
+ 'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
1312
+ );
1313
+ return;
1314
+ }
1315
+
1316
+ if (input.command.trim() === '/sandbox-enable') {
1317
+ if (!sandboxDisabled) {
1318
+ pushCommandText(
1319
+ input,
1320
+ output,
1321
+ 'Sandbox is already enabled. Use /sandbox-disable to pause.',
1322
+ );
1323
+ return;
1324
+ }
1325
+ sandboxDisabled = false;
1326
+ pushCommandText(input, output, 'Sandbox re-enabled.');
1327
+ return;
1328
+ }
1329
+
1330
+ // Check domain and filesystem in user shell commands (commands starting with !)
1218
1331
  if (input.command.startsWith('!')) {
1219
1332
  const shellCommand = input.command.slice(1).trim();
1220
1333
  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
- });
1334
+ if (!config) return;
1335
+
1336
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1337
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1338
+
1339
+ for (const path of extractCandidatePaths(shellCommand)) {
1340
+ assertReadAllowed(path, config, directory, effectiveAllowRead);
1341
+ assertWriteAllowed(path, config, directory, effectiveAllowWrite);
1342
+ }
1343
+
1344
+ if (!config.network.allowNetwork) {
1345
+ const blockedDomain = firstBlockedDomain(shellCommand, config);
1346
+ if (blockedDomain) {
1347
+ const reason =
1348
+ blockedDomain.reason === 'deniedDomains'
1349
+ ? 'is blocked by network.deniedDomains'
1350
+ : 'is not in network.allowedDomains';
1351
+ throw errorWithConfigPaths(
1352
+ directory,
1353
+ `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
1354
+ );
1355
+ }
1236
1356
  }
1237
1357
  }
1238
1358
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
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 };