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 +17 -2
- package/index.ts +460 -181
- package/landstrip.d.ts +3 -0
- package/package.json +8 -7
- 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.
|
|
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
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
1026
|
+
?.catch?.(() => undefined);
|
|
878
1027
|
|
|
879
1028
|
client.tui
|
|
880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
1405
|
+
?.showToast?.({
|
|
1168
1406
|
body: { title: 'opencode-landstrip', message, variant: 'error' },
|
|
1169
1407
|
query: { directory },
|
|
1170
1408
|
})
|
|
1171
|
-
|
|
1409
|
+
?.catch?.(() => undefined);
|
|
1172
1410
|
await client.app
|
|
1173
|
-
|
|
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
|
-
|
|
1419
|
+
?.catch?.(() => undefined);
|
|
1182
1420
|
}
|
|
1183
1421
|
|
|
1184
1422
|
const blockedPath = extractBlockedWritePath(outputText, directory, state.originalCommand);
|
|
1185
1423
|
if (blockedPath) {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
|
1435
|
+
if (input.command.trim() === '/sandbox') {
|
|
1206
1436
|
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);
|
|
1437
|
+
pushCommandText(input, output, sandboxSummary(config));
|
|
1214
1438
|
return;
|
|
1215
1439
|
}
|
|
1216
1440
|
|
|
1217
|
-
|
|
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
|
|
1222
|
-
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
54
|
-
"@opentui/core": ">=0.3.
|
|
55
|
-
"@opentui/keymap": ">=0.3.
|
|
56
|
-
"@opentui/solid": ">=0.3.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
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 };
|