opencode-landstrip 0.19.3 → 0.19.6
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 +1 -1
- package/index.ts +296 -387
- package/package.json +9 -8
- package/tui.ts +742 -730
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
|
-
import type {
|
|
4
|
+
import type { Plugin } from '@opencode/plugin';
|
|
5
5
|
|
|
6
6
|
import { randomBytes } from 'node:crypto';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
@@ -32,8 +32,6 @@ import {
|
|
|
32
32
|
normalizeOptions,
|
|
33
33
|
normalizePathSeparators,
|
|
34
34
|
parseLandstripTraps,
|
|
35
|
-
permissionPatterns,
|
|
36
|
-
permissionType,
|
|
37
35
|
readDiscoveryPort,
|
|
38
36
|
trapSessionHelloLine,
|
|
39
37
|
} from './shared.js';
|
|
@@ -652,371 +650,279 @@ function errorWithConfigPaths(baseDirectory: string, message: string): Error {
|
|
|
652
650
|
return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
|
|
653
651
|
}
|
|
654
652
|
|
|
655
|
-
const plugin: Plugin =
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
function clearCallAllowances(callID: string): void {
|
|
669
|
-
for (const key of callAllowances) {
|
|
670
|
-
if (key.startsWith(`${callID}:`)) callAllowances.delete(key);
|
|
653
|
+
const plugin: Plugin.Plugin = {
|
|
654
|
+
id: 'opencode-landstrip',
|
|
655
|
+
async setup(context: Plugin.Context) {
|
|
656
|
+
const directory = context.location?.directory ?? process.cwd();
|
|
657
|
+
const optionOverrides = normalizeOptions(context.options);
|
|
658
|
+
const pendingCallIDByCommand = new Map<string, string>();
|
|
659
|
+
const pendingSessionIDByCommand = new Map<string, string>();
|
|
660
|
+
const activeBash = new Map<string, BashSandboxState>();
|
|
661
|
+
const notified = new Set<string>();
|
|
662
|
+
let enabledNotified = false;
|
|
663
|
+
let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
|
|
664
|
+
function reportBlocked(decision: SandboxPermissionDecision): never {
|
|
665
|
+
throw errorWithConfigPaths(directory, decision.message);
|
|
671
666
|
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function hasCallAllowance(callID: string, decision: SandboxPermissionDecision): boolean {
|
|
675
|
-
return callAllowances.has(allowanceKey(callID, decision.kind, decision.resource));
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
function reportBlocked(decision: SandboxPermissionDecision): never {
|
|
679
|
-
client.tui
|
|
680
|
-
?.showToast?.({
|
|
681
|
-
body: {
|
|
682
|
-
title: 'Sandbox blocked',
|
|
683
|
-
message: decision.message.slice(0, 120),
|
|
684
|
-
variant: 'error',
|
|
685
|
-
},
|
|
686
|
-
})
|
|
687
|
-
?.catch?.(() => undefined);
|
|
688
|
-
throw errorWithConfigPaths(directory, decision.message);
|
|
689
|
-
}
|
|
690
667
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
body: {
|
|
694
|
-
service: 'opencode-landstrip',
|
|
695
|
-
level: 'info',
|
|
696
|
-
message: `plugin loaded for ${directory}`,
|
|
697
|
-
},
|
|
698
|
-
query: { directory },
|
|
699
|
-
})
|
|
700
|
-
?.catch?.(() => undefined);
|
|
701
|
-
|
|
702
|
-
client.tui
|
|
703
|
-
?.showToast?.({
|
|
704
|
-
body: {
|
|
705
|
-
title: 'Sandbox',
|
|
706
|
-
message: `Loaded for ${directory}`,
|
|
707
|
-
variant: 'info',
|
|
708
|
-
duration: 5000,
|
|
709
|
-
},
|
|
710
|
-
})
|
|
711
|
-
?.catch?.(() => undefined);
|
|
712
|
-
|
|
713
|
-
const notifyGate = new Map<string, Promise<void>>();
|
|
714
|
-
|
|
715
|
-
async function notifyOnce(key: string, message: string, variant: ToastVariant): Promise<void> {
|
|
716
|
-
if (notified.has(key)) return;
|
|
717
|
-
const pending = notifyGate.get(key);
|
|
718
|
-
if (pending) return pending;
|
|
719
|
-
|
|
720
|
-
const promise = (async () => {
|
|
668
|
+
async function notifyOnce(key: string, message: string, variant: ToastVariant): Promise<void> {
|
|
669
|
+
if (notified.has(key)) return;
|
|
721
670
|
notified.add(key);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
body: { title: 'opencode-landstrip', message, variant },
|
|
726
|
-
query: { directory },
|
|
727
|
-
})
|
|
728
|
-
?.catch?.(() => undefined);
|
|
729
|
-
|
|
730
|
-
await client.app
|
|
731
|
-
?.log?.({
|
|
732
|
-
body: {
|
|
733
|
-
service: 'opencode-landstrip',
|
|
734
|
-
level: variant === 'error' ? 'error' : variant === 'warning' ? 'warn' : 'info',
|
|
735
|
-
message,
|
|
736
|
-
},
|
|
737
|
-
query: { directory },
|
|
738
|
-
})
|
|
739
|
-
?.catch?.(() => undefined);
|
|
740
|
-
|
|
741
|
-
notifyGate.delete(key);
|
|
742
|
-
})();
|
|
743
|
-
|
|
744
|
-
notifyGate.set(key, promise);
|
|
745
|
-
return promise;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
function checkLandstrip(): typeof landstripCheck {
|
|
749
|
-
if (landstripCheck) return landstripCheck;
|
|
750
|
-
|
|
751
|
-
if (!SUPPORTED_PLATFORMS.has(process.platform)) {
|
|
752
|
-
landstripCheck = {
|
|
753
|
-
ok: false,
|
|
754
|
-
reason: `landstrip sandboxing is not supported on ${process.platform}`,
|
|
755
|
-
};
|
|
756
|
-
return landstripCheck;
|
|
671
|
+
const output =
|
|
672
|
+
variant === 'error' ? console.error : variant === 'warning' ? console.warn : console.info;
|
|
673
|
+
output(`opencode-landstrip: ${message}`);
|
|
757
674
|
}
|
|
758
675
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
|
|
762
|
-
version = result.status === 0 ? result.stdout.trim() : null;
|
|
763
|
-
} catch (error) {
|
|
764
|
-
landstripCheck = {
|
|
765
|
-
ok: false,
|
|
766
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
767
|
-
};
|
|
768
|
-
return landstripCheck;
|
|
769
|
-
}
|
|
676
|
+
function checkLandstrip(): typeof landstripCheck {
|
|
677
|
+
if (landstripCheck) return landstripCheck;
|
|
770
678
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
679
|
+
if (!SUPPORTED_PLATFORMS.has(process.platform)) {
|
|
680
|
+
landstripCheck = {
|
|
681
|
+
ok: false,
|
|
682
|
+
reason: `landstrip sandboxing is not supported on ${process.platform}`,
|
|
683
|
+
};
|
|
684
|
+
return landstripCheck;
|
|
685
|
+
}
|
|
778
686
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
687
|
+
let version: string | null;
|
|
688
|
+
try {
|
|
689
|
+
const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
|
|
690
|
+
version = result.status === 0 ? result.stdout.trim() : null;
|
|
691
|
+
} catch (error) {
|
|
692
|
+
landstripCheck = {
|
|
693
|
+
ok: false,
|
|
694
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
695
|
+
};
|
|
696
|
+
return landstripCheck;
|
|
697
|
+
}
|
|
786
698
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
699
|
+
if (!version) {
|
|
700
|
+
landstripCheck = {
|
|
701
|
+
ok: false,
|
|
702
|
+
reason: `landstrip was not found. Reinstall with: npm install @landstrip/landstrip-api`,
|
|
703
|
+
};
|
|
704
|
+
return landstripCheck;
|
|
705
|
+
}
|
|
790
706
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
);
|
|
799
|
-
return null;
|
|
800
|
-
}
|
|
707
|
+
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
708
|
+
landstripCheck = {
|
|
709
|
+
ok: false,
|
|
710
|
+
reason: `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
|
|
711
|
+
};
|
|
712
|
+
return landstripCheck;
|
|
713
|
+
}
|
|
801
714
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const reason = check?.reason ?? 'Unknown Landstrip installation error';
|
|
805
|
-
await notifyOnce(`broken-installation:${reason}`, reason, 'error');
|
|
806
|
-
throw new Error(`Broken @landstrip/landstrip-api installation: ${reason}`);
|
|
715
|
+
landstripCheck = { ok: true, version };
|
|
716
|
+
return landstripCheck;
|
|
807
717
|
}
|
|
808
718
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
if (config.
|
|
812
|
-
await notifyOnce(
|
|
813
|
-
'network-allow',
|
|
814
|
-
'Network sandbox is disabled because network.allowNetwork is true.',
|
|
815
|
-
'warning',
|
|
816
|
-
);
|
|
817
|
-
} else {
|
|
818
|
-
const networkLabel = allowsAllDomains(config.network.allowedDomains)
|
|
819
|
-
? 'all domains'
|
|
820
|
-
: `${config.network.allowedDomains.length} domains`;
|
|
719
|
+
async function activeConfig(): Promise<SandboxConfig | null> {
|
|
720
|
+
const config = loadConfig(directory, optionOverrides);
|
|
721
|
+
if (!config.enabled) {
|
|
821
722
|
await notifyOnce(
|
|
822
|
-
|
|
823
|
-
|
|
723
|
+
`not-configured:${directory}`,
|
|
724
|
+
'Sandbox is disabled by configuration',
|
|
824
725
|
'info',
|
|
825
726
|
);
|
|
826
|
-
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const check = checkLandstrip();
|
|
731
|
+
if (!check?.ok) {
|
|
732
|
+
const reason = check?.reason ?? 'Unknown Landstrip installation error';
|
|
733
|
+
await notifyOnce(`broken-installation:${reason}`, reason, 'error');
|
|
734
|
+
throw new Error(`Broken @landstrip/landstrip-api installation: ${reason}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (!enabledNotified) {
|
|
738
|
+
enabledNotified = true;
|
|
739
|
+
if (config.network.allowNetwork) {
|
|
827
740
|
await notifyOnce(
|
|
828
|
-
'network-
|
|
829
|
-
'Network sandbox
|
|
741
|
+
'network-allow',
|
|
742
|
+
'Network sandbox is disabled because network.allowNetwork is true.',
|
|
830
743
|
'warning',
|
|
831
744
|
);
|
|
745
|
+
} else {
|
|
746
|
+
const networkLabel = allowsAllDomains(config.network.allowedDomains)
|
|
747
|
+
? 'all domains'
|
|
748
|
+
: `${config.network.allowedDomains.length} domains`;
|
|
749
|
+
await notifyOnce(
|
|
750
|
+
'enabled',
|
|
751
|
+
`Sandbox enabled: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
|
|
752
|
+
'info',
|
|
753
|
+
);
|
|
754
|
+
if (allowsAllDomains(config.network.allowedDomains)) {
|
|
755
|
+
await notifyOnce(
|
|
756
|
+
'network-all',
|
|
757
|
+
'Network sandbox allows all domains because network.allowedDomains contains "*".',
|
|
758
|
+
'warning',
|
|
759
|
+
);
|
|
760
|
+
}
|
|
832
761
|
}
|
|
833
762
|
}
|
|
834
|
-
}
|
|
835
763
|
|
|
836
|
-
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
async function cleanupBash(callID: string): Promise<void> {
|
|
840
|
-
clearCallAllowances(callID);
|
|
841
|
-
const state = activeBash.get(callID);
|
|
842
|
-
if (!state) return;
|
|
843
|
-
|
|
844
|
-
activeBash.delete(callID);
|
|
845
|
-
if (state.stop) await state.stop().catch(() => undefined);
|
|
846
|
-
if (state.trapServer) {
|
|
847
|
-
await new Promise<void>((resolve) => {
|
|
848
|
-
state.trapServer!.close(() => resolve());
|
|
849
|
-
});
|
|
764
|
+
return config;
|
|
850
765
|
}
|
|
851
|
-
rmSync(state.policyDir, { recursive: true, force: true });
|
|
852
|
-
}
|
|
853
766
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
args: Record<string, unknown>,
|
|
858
|
-
config: SandboxConfig,
|
|
859
|
-
): Promise<void> {
|
|
860
|
-
if (typeof args.command !== 'string') return;
|
|
861
|
-
const normalizedSessionID = sessionID?.trim() || undefined;
|
|
862
|
-
|
|
863
|
-
const rewriteDescription = (): void => {
|
|
864
|
-
if (typeof args.description === 'string' && !args.description.endsWith(' (landstrip)')) {
|
|
865
|
-
args.description = `${args.description} (landstrip)`;
|
|
866
|
-
}
|
|
867
|
-
};
|
|
767
|
+
async function cleanupBash(callID: string): Promise<void> {
|
|
768
|
+
const state = activeBash.get(callID);
|
|
769
|
+
if (!state) return;
|
|
868
770
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
args.command = existing.wrappedCommand;
|
|
876
|
-
rewriteDescription();
|
|
877
|
-
return;
|
|
771
|
+
activeBash.delete(callID);
|
|
772
|
+
if (state.stop) await state.stop().catch(() => undefined);
|
|
773
|
+
if (state.trapServer) {
|
|
774
|
+
await new Promise<void>((resolve) => {
|
|
775
|
+
state.trapServer!.close(() => resolve());
|
|
776
|
+
});
|
|
878
777
|
}
|
|
879
|
-
|
|
880
|
-
await cleanupBash(callID);
|
|
778
|
+
rmSync(state.policyDir, { recursive: true, force: true });
|
|
881
779
|
}
|
|
882
780
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
781
|
+
async function prepareBash(
|
|
782
|
+
callID: string,
|
|
783
|
+
sessionID: string | undefined,
|
|
784
|
+
args: Record<string, unknown>,
|
|
785
|
+
config: SandboxConfig,
|
|
786
|
+
env?: Record<string, string | undefined>,
|
|
787
|
+
overrideShell?: string,
|
|
788
|
+
): Promise<void> {
|
|
789
|
+
if (typeof args.command !== 'string') return;
|
|
790
|
+
const normalizedSessionID = sessionID?.trim() || undefined;
|
|
791
|
+
|
|
792
|
+
const rewriteDescription = (): void => {
|
|
793
|
+
if (typeof args.description === 'string' && !args.description.endsWith(' (landstrip)')) {
|
|
794
|
+
args.description = `${args.description} (landstrip)`;
|
|
795
|
+
}
|
|
796
|
+
};
|
|
896
797
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
if (
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
798
|
+
const existing = activeBash.get(callID);
|
|
799
|
+
if (existing) {
|
|
800
|
+
if (
|
|
801
|
+
existing.sessionID === normalizedSessionID &&
|
|
802
|
+
(args.command === existing.originalCommand || args.command === existing.wrappedCommand)
|
|
803
|
+
) {
|
|
804
|
+
args.command = existing.wrappedCommand;
|
|
805
|
+
rewriteDescription();
|
|
806
|
+
if (env) {
|
|
807
|
+
const envVars = proxyEnv(existing.port, existing.proxyToken);
|
|
808
|
+
if (envVars) Object.assign(env, envVars);
|
|
809
|
+
}
|
|
810
|
+
return;
|
|
903
811
|
}
|
|
904
|
-
|
|
812
|
+
|
|
813
|
+
await cleanupBash(callID);
|
|
905
814
|
}
|
|
906
|
-
}
|
|
907
815
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
913
|
-
}
|
|
816
|
+
if (isGeneratedWrappedCommand(args.command as string)) {
|
|
817
|
+
if (activeBash.has(callID)) await cleanupBash(callID);
|
|
818
|
+
const original = extractOriginalCommand(args.command as string);
|
|
819
|
+
if (original) args.command = original;
|
|
820
|
+
}
|
|
914
821
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
? null
|
|
922
|
-
: await startFilterProxy({
|
|
923
|
-
isDomainAllowed: (domain) => isDomainAllowed(domain, effectiveConfig),
|
|
924
|
-
...(proxyAuthorization === undefined ? {} : { authorization: proxyAuthorization }),
|
|
925
|
-
});
|
|
926
|
-
const proxyPort = proxy ? proxy.port : null;
|
|
927
|
-
let policy: { dir: string; path: string };
|
|
928
|
-
|
|
929
|
-
try {
|
|
930
|
-
policy = writePolicyFile(effectiveConfig, directory, proxyPort);
|
|
931
|
-
} catch (error) {
|
|
932
|
-
if (proxy) await proxy.stop().catch(() => undefined);
|
|
933
|
-
throw error;
|
|
934
|
-
}
|
|
822
|
+
const allowNetwork = config.network.allowNetwork;
|
|
823
|
+
if (!allowNetwork) {
|
|
824
|
+
for (const decision of evaluateCommandDomains(args.command as string, config)) {
|
|
825
|
+
if (decision.status !== 'allow') throw errorWithConfigPaths(directory, decision.message);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
935
828
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
: null;
|
|
957
|
-
const trapPort = tuiTrapPort ?? trapServer?.port ?? null;
|
|
958
|
-
|
|
959
|
-
const wrappedCommand = buildWrappedCommand(
|
|
960
|
-
policy.path,
|
|
961
|
-
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
962
|
-
originalCommand,
|
|
963
|
-
trapPort,
|
|
964
|
-
tuiTrapPort === null ? undefined : interactiveSessionID,
|
|
965
|
-
);
|
|
829
|
+
const proxyToken = allowNetwork ? null : randomBytes(32).toString('base64url');
|
|
830
|
+
const proxyAuthorization =
|
|
831
|
+
proxyToken === null
|
|
832
|
+
? undefined
|
|
833
|
+
: `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString('base64')}`;
|
|
834
|
+
const proxy = allowNetwork
|
|
835
|
+
? null
|
|
836
|
+
: await startFilterProxy({
|
|
837
|
+
isDomainAllowed: (domain) => isDomainAllowed(domain, config),
|
|
838
|
+
...(proxyAuthorization === undefined ? {} : { authorization: proxyAuthorization }),
|
|
839
|
+
});
|
|
840
|
+
const proxyPort = proxy ? proxy.port : null;
|
|
841
|
+
let policy: { dir: string; path: string };
|
|
842
|
+
|
|
843
|
+
try {
|
|
844
|
+
policy = writePolicyFile(config, directory, proxyPort);
|
|
845
|
+
} catch (error) {
|
|
846
|
+
if (proxy) await proxy.stop().catch(() => undefined);
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
966
849
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
850
|
+
const originalCommand = args.command as string;
|
|
851
|
+
|
|
852
|
+
// The TUI owns interactive query handling. Fall back to an in-process
|
|
853
|
+
// broker when no TUI endpoint or session identity is available.
|
|
854
|
+
const interactiveSessionID = normalizedSessionID ?? '';
|
|
855
|
+
const discoveredPort =
|
|
856
|
+
process.platform === 'linux' && interactiveSessionID ? readDiscoveryPort(directory) : null;
|
|
857
|
+
const tuiTrapPort =
|
|
858
|
+
discoveredPort !== null && (await trapPortAcceptsConnections(discoveredPort))
|
|
859
|
+
? discoveredPort
|
|
860
|
+
: null;
|
|
861
|
+
const trapServer =
|
|
862
|
+
tuiTrapPort === null
|
|
863
|
+
? await startTrapServer(
|
|
864
|
+
config.filesystem.allowRead,
|
|
865
|
+
config.filesystem.allowWrite,
|
|
866
|
+
config.filesystem.denyRead,
|
|
867
|
+
config.filesystem.denyWrite,
|
|
868
|
+
directory,
|
|
869
|
+
)
|
|
870
|
+
: null;
|
|
871
|
+
const trapPort = tuiTrapPort ?? trapServer?.port ?? null;
|
|
872
|
+
|
|
873
|
+
const wrappedCommand = buildWrappedCommand(
|
|
874
|
+
policy.path,
|
|
875
|
+
overrideShell ?? process.env.SHELL ?? '/bin/sh',
|
|
876
|
+
originalCommand,
|
|
877
|
+
trapPort,
|
|
878
|
+
tuiTrapPort === null ? undefined : interactiveSessionID,
|
|
879
|
+
);
|
|
979
880
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
881
|
+
activeBash.set(callID, {
|
|
882
|
+
originalCommand,
|
|
883
|
+
wrappedCommand,
|
|
884
|
+
sessionID: normalizedSessionID,
|
|
885
|
+
policyDir: policy.dir,
|
|
886
|
+
port: proxyPort,
|
|
887
|
+
proxyToken,
|
|
888
|
+
stop: proxy ? proxy.stop : null,
|
|
889
|
+
trapServer: trapServer?.server ?? null,
|
|
890
|
+
trapServerPort: trapPort,
|
|
891
|
+
trapLines: trapServer?.trapLines ?? [],
|
|
892
|
+
});
|
|
983
893
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
894
|
+
args.command = wrappedCommand;
|
|
895
|
+
rewriteDescription();
|
|
896
|
+
if (env) {
|
|
897
|
+
const envVars = proxyEnv(proxyPort, proxyToken);
|
|
898
|
+
if (envVars) Object.assign(env, envVars);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
990
901
|
|
|
991
|
-
|
|
902
|
+
context.permission.hook('evaluate', async (evaluation) => {
|
|
903
|
+
if (evaluation.effect === 'deny') return;
|
|
992
904
|
const config = await activeConfig();
|
|
993
905
|
if (!config) return;
|
|
994
906
|
|
|
995
|
-
const
|
|
996
|
-
const
|
|
997
|
-
const metadata = isRecord(request.metadata) ? request.metadata : {};
|
|
998
|
-
const tool = isRecord(request.tool) ? request.tool : undefined;
|
|
999
|
-
const callID =
|
|
1000
|
-
typeof request.callID === 'string'
|
|
1001
|
-
? request.callID
|
|
1002
|
-
: typeof tool?.callID === 'string'
|
|
1003
|
-
? tool.callID
|
|
1004
|
-
: undefined;
|
|
1005
|
-
const patterns = permissionPatterns(request);
|
|
1006
|
-
|
|
907
|
+
const action = evaluation.action;
|
|
908
|
+
const patterns = [...(evaluation.resources ?? [])];
|
|
1007
909
|
const effectiveAllowRead = config.filesystem.allowRead;
|
|
1008
910
|
const effectiveAllowWrite = config.filesystem.allowWrite;
|
|
1009
|
-
const args: Record<string, unknown> = { ...metadata };
|
|
1010
|
-
if (
|
|
1011
|
-
if (
|
|
911
|
+
const args: Record<string, unknown> = { ...evaluation.metadata };
|
|
912
|
+
if (action === 'read' || action === 'external_directory') args.paths = patterns;
|
|
913
|
+
if (action === 'edit') {
|
|
1012
914
|
args.paths =
|
|
1013
915
|
patterns.length > 0
|
|
1014
916
|
? patterns
|
|
1015
|
-
: [
|
|
917
|
+
: [args.filepath].filter((path): path is string => typeof path === 'string');
|
|
918
|
+
}
|
|
919
|
+
if ((action === 'shell' || action === 'bash') && typeof args.command !== 'string') {
|
|
920
|
+
args.command = patterns[0];
|
|
1016
921
|
}
|
|
1017
|
-
|
|
922
|
+
const toolName =
|
|
923
|
+
action === 'shell' ? 'bash' : action === 'external_directory' ? 'read' : action;
|
|
1018
924
|
const decisions = evaluateToolPermissions(
|
|
1019
|
-
|
|
925
|
+
toolName,
|
|
1020
926
|
args,
|
|
1021
927
|
config,
|
|
1022
928
|
directory,
|
|
@@ -1026,76 +932,95 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1026
932
|
|
|
1027
933
|
const denied = decisions.find((item) => item.status === 'deny');
|
|
1028
934
|
if (denied) {
|
|
1029
|
-
|
|
935
|
+
evaluation.effect = 'deny';
|
|
936
|
+
evaluation.message = denied.message;
|
|
1030
937
|
return;
|
|
1031
938
|
}
|
|
1032
939
|
|
|
1033
940
|
const approvals = decisions.filter((item) => item.status === 'ask');
|
|
1034
941
|
if (approvals.length === 0) return;
|
|
1035
942
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
callAllowances.add(allowanceKey(callID, approval.kind, approval.resource));
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
},
|
|
1043
|
-
|
|
1044
|
-
'tool.execute.before': async (input, output) => {
|
|
1045
|
-
if (!isRecord(output.args)) return;
|
|
943
|
+
evaluation.effect = 'ask';
|
|
944
|
+
evaluation.message = approvals.map((item) => item.message).join('\n');
|
|
945
|
+
});
|
|
1046
946
|
|
|
947
|
+
context.tool.hook('execute.before', async (event) => {
|
|
1047
948
|
const config = await activeConfig();
|
|
1048
949
|
if (!config) return;
|
|
1049
950
|
|
|
1050
|
-
|
|
1051
|
-
|
|
951
|
+
const args = isRecord(event.input) ? event.input : {};
|
|
952
|
+
|
|
953
|
+
if (event.tool === 'shell') {
|
|
954
|
+
if (args.background === true) {
|
|
955
|
+
throw new Error(
|
|
956
|
+
'Background shell commands are not supported while the sandbox is enabled',
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
if (typeof args.command === 'string') {
|
|
960
|
+
await prepareBash(event.id, event.sessionID, args, config);
|
|
961
|
+
pendingCallIDByCommand.set(args.command, event.id);
|
|
962
|
+
pendingSessionIDByCommand.set(args.command, event.sessionID);
|
|
963
|
+
}
|
|
1052
964
|
return;
|
|
1053
965
|
}
|
|
1054
966
|
|
|
1055
967
|
const decisions = evaluateToolPermissions(
|
|
1056
|
-
|
|
1057
|
-
|
|
968
|
+
event.tool,
|
|
969
|
+
args,
|
|
1058
970
|
config,
|
|
1059
971
|
directory,
|
|
1060
972
|
config.filesystem.allowRead,
|
|
1061
973
|
config.filesystem.allowWrite,
|
|
1062
974
|
);
|
|
1063
975
|
for (const decision of decisions) {
|
|
1064
|
-
if (
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
) {
|
|
976
|
+
if (decision.status === 'allow') continue;
|
|
977
|
+
if (decision.status === 'ask' && ['read', 'write', 'edit', 'patch'].includes(event.tool)) {
|
|
978
|
+
// These host tools assert their own permission during execution.
|
|
1068
979
|
continue;
|
|
1069
980
|
}
|
|
1070
981
|
reportBlocked(decision);
|
|
1071
982
|
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
'shell.env': async (input, output) => {
|
|
1075
|
-
if (!input.callID) return;
|
|
1076
|
-
const state = activeBash.get(input.callID);
|
|
1077
|
-
if (!state) return;
|
|
983
|
+
});
|
|
1078
984
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
985
|
+
context.shell.hook('create.before', async (invocation) => {
|
|
986
|
+
const config = await activeConfig();
|
|
987
|
+
if (!config) return;
|
|
1082
988
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
989
|
+
let callID = pendingCallIDByCommand.get(invocation.command);
|
|
990
|
+
let sessionID = pendingSessionIDByCommand.get(invocation.command);
|
|
991
|
+
if (callID) {
|
|
992
|
+
pendingCallIDByCommand.delete(invocation.command);
|
|
993
|
+
} else {
|
|
994
|
+
callID = `shell-${randomBytes(6).toString('hex')}`;
|
|
1087
995
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
if (!state) {
|
|
1091
|
-
await cleanupBash(input.callID);
|
|
1092
|
-
return;
|
|
996
|
+
if (sessionID) {
|
|
997
|
+
pendingSessionIDByCommand.delete(invocation.command);
|
|
1093
998
|
}
|
|
1094
999
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1000
|
+
await prepareBash(
|
|
1001
|
+
callID,
|
|
1002
|
+
sessionID,
|
|
1003
|
+
invocation as unknown as Record<string, unknown>,
|
|
1004
|
+
config,
|
|
1005
|
+
invocation.env,
|
|
1006
|
+
invocation.shell,
|
|
1007
|
+
);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
context.tool.hook('execute.after', async (event) => {
|
|
1011
|
+
if (event.tool !== 'shell') return;
|
|
1012
|
+
|
|
1013
|
+
const state = activeBash.get(event.id);
|
|
1014
|
+
if (!state) return;
|
|
1015
|
+
|
|
1016
|
+
const outputText =
|
|
1017
|
+
event.status === 'completed'
|
|
1018
|
+
? typeof event.result.output === 'string'
|
|
1019
|
+
? event.result.output
|
|
1020
|
+
: typeof event.result.content === 'string'
|
|
1021
|
+
? event.result.content
|
|
1022
|
+
: ''
|
|
1023
|
+
: '';
|
|
1099
1024
|
const serverTrapOutput = state.trapLines.join('\n');
|
|
1100
1025
|
const combinedOutput = serverTrapOutput ? outputText + '\n' + serverTrapOutput : outputText;
|
|
1101
1026
|
const traps = parseLandstripTraps(combinedOutput);
|
|
@@ -1103,23 +1028,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1103
1028
|
(trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
|
|
1104
1029
|
);
|
|
1105
1030
|
if (errors.length > 0) {
|
|
1106
|
-
|
|
1107
|
-
await client.tui
|
|
1108
|
-
?.showToast?.({
|
|
1109
|
-
body: { title: 'opencode-landstrip', message, variant: 'error' },
|
|
1110
|
-
query: { directory },
|
|
1111
|
-
})
|
|
1112
|
-
?.catch?.(() => undefined);
|
|
1113
|
-
await client.app
|
|
1114
|
-
?.log?.({
|
|
1115
|
-
body: {
|
|
1116
|
-
service: 'opencode-landstrip',
|
|
1117
|
-
level: 'error',
|
|
1118
|
-
message,
|
|
1119
|
-
},
|
|
1120
|
-
query: { directory },
|
|
1121
|
-
})
|
|
1122
|
-
?.catch?.(() => undefined);
|
|
1031
|
+
console.error(`opencode-landstrip: ${formatLandstripTraps(errors)}`);
|
|
1123
1032
|
}
|
|
1124
1033
|
|
|
1125
1034
|
const blockedTrap = traps.find(
|
|
@@ -1138,15 +1047,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1138
1047
|
);
|
|
1139
1048
|
}
|
|
1140
1049
|
|
|
1141
|
-
await cleanupBash(
|
|
1142
|
-
}
|
|
1050
|
+
await cleanupBash(event.id);
|
|
1051
|
+
});
|
|
1143
1052
|
|
|
1144
|
-
|
|
1053
|
+
return async () => {
|
|
1145
1054
|
await Promise.all([...activeBash.keys()].map((callID) => cleanupBash(callID)));
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
return hooks;
|
|
1055
|
+
activeBash.clear();
|
|
1056
|
+
};
|
|
1057
|
+
},
|
|
1150
1058
|
};
|
|
1151
1059
|
|
|
1152
|
-
export
|
|
1060
|
+
export { plugin };
|
|
1061
|
+
export default plugin;
|