opencode-landstrip 0.3.8 → 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.
- package/README.md +17 -2
- package/index.ts +215 -95
- package/package.json +1 -1
- package/tui.ts +261 -40
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.
|
|
37
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
964
|
+
?.catch?.(() => undefined);
|
|
878
965
|
|
|
879
966
|
client.tui
|
|
880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1242
|
+
?.showToast?.({
|
|
1168
1243
|
body: { title: 'opencode-landstrip', message, variant: 'error' },
|
|
1169
1244
|
query: { directory },
|
|
1170
1245
|
})
|
|
1171
|
-
|
|
1246
|
+
?.catch?.(() => undefined);
|
|
1172
1247
|
await client.app
|
|
1173
|
-
|
|
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
|
-
|
|
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
|
|
1292
|
+
if (input.command.trim() === '/sandbox') {
|
|
1206
1293
|
const config = loadConfig(directory, optionOverrides);
|
|
1207
|
-
|
|
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
|
-
|
|
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
|
|
1222
|
-
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
package/tui.ts
CHANGED
|
@@ -3,48 +3,269 @@
|
|
|
3
3
|
|
|
4
4
|
import type { TuiPlugin } from '@opencode-ai/plugin/tui';
|
|
5
5
|
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
const client = api.client;
|
|
27
|
-
if (client?.tui?.showToast) {
|
|
28
|
-
client.tui
|
|
29
|
-
.showToast({
|
|
30
|
-
title: 'Sandbox',
|
|
31
|
-
message: '/sandbox command registered',
|
|
32
|
-
variant: 'info',
|
|
33
|
-
})
|
|
34
|
-
.catch(() => undefined);
|
|
35
|
-
}
|
|
36
|
-
} catch (err) {
|
|
37
|
-
const client = api.client;
|
|
38
|
-
if (client?.tui?.showToast) {
|
|
39
|
-
client.tui
|
|
40
|
-
.showToast({
|
|
41
|
-
title: 'Sandbox error',
|
|
42
|
-
message: err instanceof Error ? err.message : String(err),
|
|
43
|
-
variant: 'error',
|
|
44
|
-
})
|
|
45
|
-
.catch(() => undefined);
|
|
46
|
-
}
|
|
175
|
+
return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
176
|
+
} catch {
|
|
177
|
+
return {};
|
|
47
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
|
+
]);
|
|
48
269
|
};
|
|
49
270
|
|
|
50
271
|
export { tui };
|