opencode-landstrip 0.19.3 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +1 -1
  2. package/index.ts +296 -387
  3. package/package.json +9 -8
  4. 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 { Hooks, Plugin, PluginInput, PluginOptions } from '@opencode-ai/plugin';
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 = async ({ client, directory }: PluginInput, options?: PluginOptions) => {
656
- const optionOverrides = normalizeOptions(options);
657
- const activeBash = new Map<string, BashSandboxState>();
658
- const notified = new Set<string>();
659
- const callAllowances = new Set<string>();
660
- let enabledNotified = false;
661
- let configuredShell: string | undefined;
662
- let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
663
-
664
- function allowanceKey(callID: string, kind: SandboxPermissionKind, resource: string): string {
665
- return `${callID}:${kind}:${resource}`;
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
- client.app
692
- ?.log?.({
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
- await client.tui
724
- ?.showToast?.({
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
- let version: string | null;
760
- try {
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
- if (!version) {
772
- landstripCheck = {
773
- ok: false,
774
- reason: `landstrip was not found. Reinstall with: npm install @landstrip/landstrip-api`,
775
- };
776
- return landstripCheck;
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
- if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
780
- landstripCheck = {
781
- ok: false,
782
- reason: `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
783
- };
784
- return landstripCheck;
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
- landstripCheck = { ok: true, version };
788
- return landstripCheck;
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
- async function activeConfig(): Promise<SandboxConfig | null> {
792
- const config = loadConfig(directory, optionOverrides);
793
- if (!config.enabled) {
794
- await notifyOnce(
795
- `not-configured:${directory}`,
796
- 'Sandbox is disabled by configuration',
797
- 'info',
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
- const check = checkLandstrip();
803
- if (!check?.ok) {
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
- if (!enabledNotified) {
810
- enabledNotified = true;
811
- if (config.network.allowNetwork) {
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
- 'enabled',
823
- `Sandbox enabled: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
723
+ `not-configured:${directory}`,
724
+ 'Sandbox is disabled by configuration',
824
725
  'info',
825
726
  );
826
- if (allowsAllDomains(config.network.allowedDomains)) {
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-all',
829
- 'Network sandbox allows all domains because network.allowedDomains contains "*".',
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
- return config;
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
- async function prepareBash(
855
- callID: string,
856
- sessionID: string | undefined,
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
- const existing = activeBash.get(callID);
870
- if (existing) {
871
- if (
872
- existing.sessionID === normalizedSessionID &&
873
- (args.command === existing.originalCommand || args.command === existing.wrappedCommand)
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
- if (isGeneratedWrappedCommand(args.command as string)) {
884
- if (activeBash.has(callID)) await cleanupBash(callID);
885
- const original = extractOriginalCommand(args.command as string);
886
- if (original) args.command = original;
887
- }
888
-
889
- const allowNetwork = config.network.allowNetwork;
890
- const callAllowedDomains: string[] = [];
891
- const effectiveConfig = {
892
- ...config,
893
- network: { ...config.network },
894
- filesystem: config.filesystem,
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
- if (!allowNetwork) {
898
- for (const decision of evaluateCommandDomains(args.command as string, effectiveConfig)) {
899
- if (decision.status === 'allow') continue;
900
- if (decision.status === 'ask' && hasCallAllowance(callID, decision)) {
901
- callAllowedDomains.push(decision.resource);
902
- continue;
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
- throw errorWithConfigPaths(directory, decision.message);
812
+
813
+ await cleanupBash(callID);
905
814
  }
906
- }
907
815
 
908
- if (callAllowedDomains.length > 0) {
909
- effectiveConfig.network = {
910
- ...effectiveConfig.network,
911
- allowedDomains: [...effectiveConfig.network.allowedDomains, ...callAllowedDomains],
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
- const proxyToken = allowNetwork ? null : randomBytes(32).toString('base64url');
916
- const proxyAuthorization =
917
- proxyToken === null
918
- ? undefined
919
- : `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString('base64')}`;
920
- const proxy = allowNetwork
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
- const originalCommand = args.command as string;
937
-
938
- // The TUI owns interactive query handling. Fall back to an in-process
939
- // broker when no TUI endpoint or session identity is available.
940
- const interactiveSessionID = normalizedSessionID ?? '';
941
- const discoveredPort =
942
- process.platform === 'linux' && interactiveSessionID ? readDiscoveryPort(directory) : null;
943
- const tuiTrapPort =
944
- discoveredPort !== null && (await trapPortAcceptsConnections(discoveredPort))
945
- ? discoveredPort
946
- : null;
947
- const trapServer =
948
- tuiTrapPort === null
949
- ? await startTrapServer(
950
- effectiveConfig.filesystem.allowRead,
951
- effectiveConfig.filesystem.allowWrite,
952
- effectiveConfig.filesystem.denyRead,
953
- effectiveConfig.filesystem.denyWrite,
954
- directory,
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
- activeBash.set(callID, {
968
- originalCommand,
969
- wrappedCommand,
970
- sessionID: normalizedSessionID,
971
- policyDir: policy.dir,
972
- port: proxyPort,
973
- proxyToken,
974
- stop: proxy ? proxy.stop : null,
975
- trapServer: trapServer?.server ?? null,
976
- trapServerPort: trapPort,
977
- trapLines: trapServer?.trapLines ?? [],
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
- args.command = wrappedCommand;
981
- rewriteDescription();
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
- const hooks: Hooks = {
985
- config: async (config) => {
986
- const value: unknown = config;
987
- configuredShell =
988
- isRecord(value) && typeof value.shell === 'string' ? value.shell : undefined;
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
- 'permission.ask': async (input, output) => {
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 request = input as Record<string, unknown>;
996
- const permission = permissionType(request);
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 (permission === 'read') args.paths = patterns;
1011
- if (permission === 'edit') {
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
- : [metadata.filepath].filter((path): path is string => typeof path === 'string');
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
- if (permission === 'bash' && typeof args.command !== 'string') args.command = patterns[0];
922
+ const toolName =
923
+ action === 'shell' ? 'bash' : action === 'external_directory' ? 'read' : action;
1018
924
  const decisions = evaluateToolPermissions(
1019
- permission,
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
- output.status = 'deny';
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
- output.status = 'ask';
1037
- if (callID) {
1038
- for (const approval of approvals) {
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
- if (input.tool === 'bash') {
1051
- await prepareBash(input.callID, input.sessionID, output.args, config);
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
- input.tool,
1057
- output.args,
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
- decision.status === 'allow' ||
1066
- (decision.status === 'ask' && hasCallAllowance(input.callID, decision))
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
- const envVars = proxyEnv(state.port, state.proxyToken);
1080
- if (envVars) Object.assign(output.env, envVars);
1081
- },
985
+ context.shell.hook('create.before', async (invocation) => {
986
+ const config = await activeConfig();
987
+ if (!config) return;
1082
988
 
1083
- 'tool.execute.after': async (input, output) => {
1084
- if (input.tool !== 'bash') {
1085
- clearCallAllowances(input.callID);
1086
- return;
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
- const state = activeBash.get(input.callID);
1090
- if (!state) {
1091
- await cleanupBash(input.callID);
1092
- return;
996
+ if (sessionID) {
997
+ pendingSessionIDByCommand.delete(invocation.command);
1093
998
  }
1094
999
 
1095
- const outputText = output?.output ?? '';
1096
- // Query traps were already resolved in-process by the local trap server;
1097
- // only terminal (info) traps and trap-server-collected lines belong in
1098
- // the after-the-fact toast.
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
- const message = formatLandstripTraps(errors);
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(input.callID);
1142
- },
1050
+ await cleanupBash(event.id);
1051
+ });
1143
1052
 
1144
- dispose: async () => {
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 default { server: plugin };
1060
+ export { plugin };
1061
+ export default plugin;