opencode-landstrip 0.3.10 → 0.3.12

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/index.ts CHANGED
@@ -53,11 +53,12 @@ interface LandstripPolicy {
53
53
  }
54
54
 
55
55
  interface LandstripErrorResponse {
56
- reason: 'Other' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
56
+ reason: 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
57
57
  file?: string;
58
+ operation?: 'read' | 'write';
58
59
  program?: string;
59
60
  type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
60
- source: string;
61
+ source?: string;
61
62
  }
62
63
 
63
64
  interface SandboxConfigOverrides {
@@ -74,10 +75,59 @@ interface BashSandboxState {
74
75
  stop: (() => Promise<void>) | null;
75
76
  }
76
77
 
78
+ type SandboxPermissionKind = 'read' | 'write' | 'domain';
79
+
80
+ interface SandboxPermissionDecision {
81
+ status: 'allow' | 'ask' | 'deny';
82
+ kind: SandboxPermissionKind;
83
+ resource: string;
84
+ message: string;
85
+ }
86
+
77
87
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
78
88
 
79
- const LANDSTRIP_VERSION = [0, 11, 0] as const;
89
+ const LANDSTRIP_VERSION = [0, 11, 9] as const;
90
+ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
91
+ const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorResponse['reason']>([
92
+ 'Other',
93
+ 'AccessDenied',
94
+ 'LaunchFailed',
95
+ 'SetupFailed',
96
+ 'Usage',
97
+ ]);
98
+ const LANDSTRIP_OPERATIONS = new Set<NonNullable<LandstripErrorResponse['operation']>>([
99
+ 'read',
100
+ 'write',
101
+ ]);
102
+ const LANDSTRIP_ERROR_TYPES = new Set<NonNullable<LandstripErrorResponse['type']>>([
103
+ 'filesystem',
104
+ 'network',
105
+ 'platform',
106
+ 'launch',
107
+ 'encoding',
108
+ ]);
80
109
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
110
+ const LANDSTRIP_PACKAGE_NAMES = new Set([
111
+ '@jarkkojs/landstrip',
112
+ '@jarkkojs/landstrip-darwin-arm64',
113
+ '@jarkkojs/landstrip-darwin-x64',
114
+ '@jarkkojs/landstrip-linux-x64',
115
+ '@jarkkojs/landstrip-win32-x64',
116
+ ]);
117
+
118
+ function isLandstripErrorReason(value: string): value is LandstripErrorResponse['reason'] {
119
+ return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorResponse['reason']);
120
+ }
121
+
122
+ function isLandstripOperation(
123
+ value: string,
124
+ ): value is NonNullable<LandstripErrorResponse['operation']> {
125
+ return LANDSTRIP_OPERATIONS.has(value as NonNullable<LandstripErrorResponse['operation']>);
126
+ }
127
+
128
+ function isLandstripErrorType(value: string): value is NonNullable<LandstripErrorResponse['type']> {
129
+ return LANDSTRIP_ERROR_TYPES.has(value as NonNullable<LandstripErrorResponse['type']>);
130
+ }
81
131
 
82
132
  const DEFAULT_CONFIG: SandboxConfig = {
83
133
  enabled: true,
@@ -86,36 +136,14 @@ const DEFAULT_CONFIG: SandboxConfig = {
86
136
  allowLocalBinding: false,
87
137
  allowAllUnixSockets: false,
88
138
  allowUnixSockets: [],
89
- allowedDomains: [
90
- 'npmjs.org',
91
- '*.npmjs.org',
92
- 'registry.npmjs.org',
93
- 'registry.yarnpkg.com',
94
- 'pypi.org',
95
- '*.pypi.org',
96
- 'github.com',
97
- '*.github.com',
98
- 'api.github.com',
99
- 'raw.githubusercontent.com',
100
- 'crates.io',
101
- '*.crates.io',
102
- 'static.crates.io',
103
- ],
139
+ allowedDomains: [],
104
140
  deniedDomains: [],
105
141
  },
106
142
  filesystem: {
107
143
  denyRead: ['/Users', '/home'],
108
- allowRead: [
109
- '.',
110
- '/dev/null',
111
- '~/.config/opencode',
112
- '~/.config/git',
113
- '~/.gitconfig',
114
- '~/.local',
115
- '~/.cargo',
116
- ],
117
- allowWrite: ['.', '/tmp', '/dev/null'],
118
- denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
144
+ allowRead: ['.', '~/.gitconfig', '/dev/null'],
145
+ allowWrite: ['.', '/dev/null'],
146
+ denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
119
147
  },
120
148
  };
121
149
 
@@ -189,16 +217,30 @@ function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOver
189
217
  return normalizeConfig(isRecord(options.config) ? options.config : options);
190
218
  }
191
219
 
220
+ function mergeArray(base: string[], override?: string[]): string[] {
221
+ if (!override) return base;
222
+ return [...new Set([...base, ...override])];
223
+ }
224
+
192
225
  function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
226
+ const network = overrides.network;
227
+ const filesystem = overrides.filesystem;
228
+
193
229
  return {
194
230
  enabled: overrides.enabled ?? base.enabled,
195
231
  network: {
196
- ...base.network,
197
- ...overrides.network,
232
+ allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
233
+ allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
234
+ allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
235
+ allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
236
+ allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
237
+ deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
198
238
  },
199
239
  filesystem: {
200
- ...base.filesystem,
201
- ...overrides.filesystem,
240
+ denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
241
+ allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
242
+ allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
243
+ denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
202
244
  },
203
245
  };
204
246
  }
@@ -239,6 +281,33 @@ function configuredShellPath(config: unknown): string | undefined {
239
281
  return typeof config.shell === 'string' ? config.shell : undefined;
240
282
  }
241
283
 
284
+ function landstripBinaryPath(): string {
285
+ const filePath = realpathSync.native(binaryPath());
286
+ let probe = dirname(filePath);
287
+
288
+ while (true) {
289
+ const manifestPath = join(probe, 'package.json');
290
+ if (existsSync(manifestPath)) {
291
+ try {
292
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
293
+ if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
294
+ return filePath;
295
+ }
296
+ } catch {
297
+ // malformed package.json — continue walking to parent
298
+ }
299
+ }
300
+
301
+ const parent = dirname(probe);
302
+ if (parent === probe) break;
303
+ probe = parent;
304
+ }
305
+
306
+ throw new Error(
307
+ `Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
308
+ );
309
+ }
310
+
242
311
  function canonicalizePath(filePath: string, baseDirectory: string): string {
243
312
  const abs = expandPath(filePath, baseDirectory);
244
313
 
@@ -434,8 +503,91 @@ function firstBlockedDomain(
434
503
  return null;
435
504
  }
436
505
 
506
+ function evaluateReadPermission(
507
+ path: string,
508
+ config: SandboxConfig,
509
+ baseDirectory: string,
510
+ effectiveAllowRead: string[],
511
+ ): SandboxPermissionDecision {
512
+ const filePath = canonicalizePath(path, baseDirectory);
513
+
514
+ if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
515
+ return { status: 'allow', kind: 'read', resource: filePath, message: '' };
516
+ }
517
+
518
+ if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
519
+ return {
520
+ status: 'deny',
521
+ kind: 'read',
522
+ resource: filePath,
523
+ message: `Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
524
+ };
525
+ }
526
+
527
+ return {
528
+ status: 'ask',
529
+ kind: 'read',
530
+ resource: filePath,
531
+ message: `Sandbox: read access requires approval for "${filePath}" (not in filesystem.allowRead).`,
532
+ };
533
+ }
534
+
535
+ function evaluateWritePermission(
536
+ path: string,
537
+ config: SandboxConfig,
538
+ baseDirectory: string,
539
+ effectiveAllowWrite: string[],
540
+ ): SandboxPermissionDecision {
541
+ const filePath = canonicalizePath(path, baseDirectory);
542
+
543
+ if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
544
+ return { status: 'allow', kind: 'write', resource: filePath, message: '' };
545
+ }
546
+
547
+ if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
548
+ return {
549
+ status: 'deny',
550
+ kind: 'write',
551
+ resource: filePath,
552
+ message: `Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
553
+ };
554
+ }
555
+
556
+ return {
557
+ status: 'ask',
558
+ kind: 'write',
559
+ resource: filePath,
560
+ message: `Sandbox: write access requires approval for "${filePath}" (not in filesystem.allowWrite).`,
561
+ };
562
+ }
563
+
564
+ function evaluateDomainPermission(
565
+ domain: string,
566
+ config: SandboxConfig,
567
+ ): SandboxPermissionDecision {
568
+ if (config.network.allowNetwork || domainMatchesAny(domain, config.network.allowedDomains)) {
569
+ return { status: 'allow', kind: 'domain', resource: domain, message: '' };
570
+ }
571
+
572
+ if (domainMatchesAny(domain, config.network.deniedDomains)) {
573
+ return {
574
+ status: 'deny',
575
+ kind: 'domain',
576
+ resource: domain,
577
+ message: `Sandbox: network access denied for "${domain}" (is blocked by network.deniedDomains).`,
578
+ };
579
+ }
580
+
581
+ return {
582
+ status: 'ask',
583
+ kind: 'domain',
584
+ resource: domain,
585
+ message: `Sandbox: network access requires approval for "${domain}" (not in network.allowedDomains).`,
586
+ };
587
+ }
588
+
437
589
  function landstripVersion(): string | null {
438
- const result = spawnSync(binaryPath(), ['--version'], { encoding: 'utf-8' });
590
+ const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
439
591
  if (result.status !== 0) return null;
440
592
  return result.stdout.trim();
441
593
  }
@@ -472,25 +624,19 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
472
624
  if (key.length > 0 && value.length > 0) fields[key] = value;
473
625
  }
474
626
 
475
- if (
476
- fields.reason &&
477
- ['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
478
- fields.source
479
- ) {
627
+ if (fields.reason && isLandstripErrorReason(fields.reason)) {
480
628
  const error: LandstripErrorResponse = {
481
- reason: fields.reason as LandstripErrorResponse['reason'],
482
- source: fields.source,
629
+ reason: fields.reason,
483
630
  };
484
631
 
485
632
  if (fields.file) error.file = fields.file;
633
+ if (fields.operation && isLandstripOperation(fields.operation)) {
634
+ error.operation = fields.operation;
635
+ }
486
636
  if (fields.program) error.program = fields.program;
637
+ if (fields.source) error.source = fields.source;
487
638
 
488
- if (
489
- fields.type &&
490
- ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
491
- ) {
492
- error.type = fields.type as LandstripErrorResponse['type'];
493
- }
639
+ if (fields.type && isLandstripErrorType(fields.type)) error.type = fields.type;
494
640
 
495
641
  errors.push(error);
496
642
  }
@@ -507,13 +653,16 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
507
653
  if (err.file) {
508
654
  parts.push(` (${err.file})`);
509
655
  }
656
+ if (err.operation) {
657
+ parts.push(` ${err.operation}`);
658
+ }
510
659
  if (err.program) {
511
660
  parts.push(` ${err.program}`);
512
661
  }
513
662
  if (err.type) {
514
663
  parts.push(`:${err.type}`);
515
664
  }
516
- parts.push(`: ${err.source}`);
665
+ if (err.source) parts.push(`: ${err.source}`);
517
666
 
518
667
  return parts.join('');
519
668
  })
@@ -746,12 +895,12 @@ function shellArgs(shell: string, command: string): string[] {
746
895
  function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
747
896
  const args = ['-p', policyPath, ...shellArgs(shell, command)];
748
897
 
749
- return [binaryPath(), ...args].map(shellQuote).join(' ');
898
+ return [landstripBinaryPath(), ...args].map(shellQuote).join(' ');
750
899
  }
751
900
 
752
901
  function isGeneratedWrappedCommand(command: string): boolean {
753
902
  return (
754
- command.startsWith(`${shellQuote(binaryPath())} `) &&
903
+ command.startsWith(`${shellQuote(landstripBinaryPath())} `) &&
755
904
  command.includes(` ${shellQuote('-p')} `) &&
756
905
  command.includes('opencode-landstrip-')
757
906
  );
@@ -829,69 +978,14 @@ function errorWithConfigPaths(baseDirectory: string, message: string): Error {
829
978
  return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
830
979
  }
831
980
 
832
- function assertReadAllowed(
833
- path: string,
834
- config: SandboxConfig,
835
- baseDirectory: string,
836
- effectiveAllowRead: string[],
837
- ): void {
838
- const filePath = canonicalizePath(path, baseDirectory);
839
-
840
- if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) return;
841
-
842
- if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
843
- throw errorWithConfigPaths(
844
- baseDirectory,
845
- `Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
846
- );
847
- }
848
-
849
- throw errorWithConfigPaths(
850
- baseDirectory,
851
- `Sandbox: read access denied for "${filePath}" (not in filesystem.allowRead).`,
852
- );
853
- }
854
-
855
- function assertWriteAllowed(
856
- path: string,
857
- config: SandboxConfig,
858
- baseDirectory: string,
859
- effectiveAllowWrite: string[],
860
- ): void {
861
- const filePath = canonicalizePath(path, baseDirectory);
862
-
863
- if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) return;
864
-
865
- if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
866
- throw errorWithConfigPaths(
867
- baseDirectory,
868
- `Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
869
- );
870
- }
871
-
872
- throw errorWithConfigPaths(
873
- baseDirectory,
874
- `Sandbox: write access denied for "${filePath}" (not in filesystem.allowWrite).`,
875
- );
876
- }
877
-
878
- function assertApplyPatchAllowed(
879
- args: Record<string, unknown>,
880
- config: SandboxConfig,
881
- baseDirectory: string,
882
- effectiveAllowWrite: string[],
883
- ): void {
884
- if (typeof args.patchText !== 'string') return;
885
- for (const path of extractPatchPaths(args.patchText))
886
- assertWriteAllowed(path, config, baseDirectory, effectiveAllowWrite);
887
- }
888
-
889
981
  const plugin: Plugin = async ({ client, directory }: PluginInput, options?: PluginOptions) => {
890
982
  const optionOverrides = normalizeOptions(options);
891
983
  const activeBash = new Map<string, BashSandboxState>();
892
984
  const notified = new Set<string>();
893
985
  const sessionAllowedReadPaths: string[] = [];
894
986
  const sessionAllowedWritePaths: string[] = [];
987
+ const sessionAllowedDomains: string[] = [];
988
+ const callAllowances = new Set<string>();
895
989
  let enabledNotified = false;
896
990
  let sandboxDisabled = false;
897
991
  let configuredShell: string | undefined;
@@ -905,6 +999,40 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
905
999
  return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
906
1000
  }
907
1001
 
1002
+ function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
1003
+ return [...config.network.allowedDomains, ...sessionAllowedDomains];
1004
+ }
1005
+
1006
+ function allowanceKey(callID: string, kind: SandboxPermissionKind, resource: string): string {
1007
+ return `${callID}:${kind}:${resource}`;
1008
+ }
1009
+
1010
+ function rememberCallAllowance(
1011
+ callID: string | undefined,
1012
+ decision: SandboxPermissionDecision,
1013
+ ): void {
1014
+ if (!callID || decision.status === 'deny') return;
1015
+ callAllowances.add(allowanceKey(callID, decision.kind, decision.resource));
1016
+ }
1017
+
1018
+ function hasCallAllowance(callID: string, decision: SandboxPermissionDecision): boolean {
1019
+ return callAllowances.has(allowanceKey(callID, decision.kind, decision.resource));
1020
+ }
1021
+
1022
+ function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
1023
+ if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
1024
+ client.tui
1025
+ ?.showToast?.({
1026
+ body: {
1027
+ title: 'Sandbox blocked',
1028
+ message: decision.message.slice(0, 120),
1029
+ variant: 'error',
1030
+ },
1031
+ })
1032
+ ?.catch?.(() => undefined);
1033
+ throw errorWithConfigPaths(directory, decision.message);
1034
+ }
1035
+
908
1036
  function pushCommandText(
909
1037
  input: { sessionID: string },
910
1038
  output: { parts: unknown[] },
@@ -922,7 +1050,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
922
1050
  function sandboxSummary(config: SandboxConfig): string {
923
1051
  const { globalPath, projectPath } = getConfigPaths(directory);
924
1052
  const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
925
- const allowed = config.network.allowedDomains.join(', ') || '(none)';
1053
+ const allowed = getEffectiveAllowedDomains(config).join(', ') || '(none)';
926
1054
  const denied = config.network.deniedDomains.join(', ') || '(none)';
927
1055
  const denyRead = config.filesystem.denyRead.join(', ') || '(none)';
928
1056
  const allowRead = getEffectiveAllowRead(config).join(', ') || '(none)';
@@ -933,7 +1061,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
933
1061
  '# Sandbox Configuration',
934
1062
  '',
935
1063
  `Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
936
- `landstrip: ${binaryPath()}`,
1064
+ `landstrip package binary: ${landstripBinaryPath()}`,
937
1065
  '',
938
1066
  'Config files:',
939
1067
  `- project: ${projectPath}`,
@@ -1020,7 +1148,17 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1020
1148
  return landstripCheck;
1021
1149
  }
1022
1150
 
1023
- const version = landstripVersion();
1151
+ let version: string | null;
1152
+ try {
1153
+ version = landstripVersion();
1154
+ } catch (error) {
1155
+ landstripCheck = {
1156
+ ok: false,
1157
+ reason: error instanceof Error ? error.message : String(error),
1158
+ };
1159
+ return landstripCheck;
1160
+ }
1161
+
1024
1162
  if (!version) {
1025
1163
  landstripCheck = {
1026
1164
  ok: false,
@@ -1032,7 +1170,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1032
1170
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
1033
1171
  landstripCheck = {
1034
1172
  ok: false,
1035
- reason: `landstrip 0.11.0 or newer is required; found: ${version}`,
1173
+ reason: `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
1036
1174
  };
1037
1175
  return landstripCheck;
1038
1176
  }
@@ -1045,7 +1183,14 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1045
1183
  if (sandboxDisabled) return null;
1046
1184
 
1047
1185
  const config = loadConfig(directory, optionOverrides);
1048
- if (!config.enabled) return null;
1186
+ if (!config.enabled) {
1187
+ await notifyOnce(
1188
+ `not-configured:${directory}`,
1189
+ 'Sandbox is not configured — no sandbox.json5 found',
1190
+ 'info',
1191
+ );
1192
+ return null;
1193
+ }
1049
1194
 
1050
1195
  const check = checkLandstrip();
1051
1196
  if (!check?.ok) {
@@ -1130,27 +1275,37 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1130
1275
  }
1131
1276
 
1132
1277
  const allowNetwork = config.network.allowNetwork;
1278
+ const callAllowedDomains: string[] = [];
1279
+ const effectiveConfig = {
1280
+ ...config,
1281
+ network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1282
+ };
1133
1283
 
1134
1284
  if (!allowNetwork) {
1135
- const blockedDomain = firstBlockedDomain(args.command as string, config);
1136
- if (blockedDomain) {
1137
- const reason =
1138
- blockedDomain.reason === 'deniedDomains'
1139
- ? 'is blocked by network.deniedDomains'
1140
- : 'is not in network.allowedDomains';
1141
- throw errorWithConfigPaths(
1142
- directory,
1143
- `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
1144
- );
1285
+ for (const domain of extractDomainsFromCommand(args.command as string)) {
1286
+ const decision = evaluateDomainPermission(domain, effectiveConfig);
1287
+ if (decision.status === 'allow') continue;
1288
+ if (decision.status === 'ask' && hasCallAllowance(callID, decision)) {
1289
+ callAllowedDomains.push(domain);
1290
+ continue;
1291
+ }
1292
+ throw errorWithConfigPaths(directory, decision.message);
1145
1293
  }
1146
1294
  }
1147
1295
 
1148
- const proxy = allowNetwork ? null : await startProxy(config);
1296
+ if (callAllowedDomains.length > 0) {
1297
+ effectiveConfig.network = {
1298
+ ...effectiveConfig.network,
1299
+ allowedDomains: [...effectiveConfig.network.allowedDomains, ...callAllowedDomains],
1300
+ };
1301
+ }
1302
+
1303
+ const proxy = allowNetwork ? null : await startProxy(effectiveConfig);
1149
1304
  const proxyPort = proxy ? proxy.port : null;
1150
1305
  let policy: { dir: string; path: string };
1151
1306
 
1152
1307
  try {
1153
- policy = writePolicyFile(config, directory, proxyPort);
1308
+ policy = writePolicyFile(effectiveConfig, directory, proxyPort);
1154
1309
  } catch (error) {
1155
1310
  if (proxy) await proxy.stop().catch(() => undefined);
1156
1311
  throw error;
@@ -1181,6 +1336,80 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1181
1336
  configuredShell = configuredShellPath(config);
1182
1337
  },
1183
1338
 
1339
+ 'permission.ask': async (input, output) => {
1340
+ const config = await activeConfig();
1341
+ if (!config) return;
1342
+
1343
+ const request = input as Record<string, unknown>;
1344
+ const permission =
1345
+ typeof request.type === 'string'
1346
+ ? request.type
1347
+ : typeof request.permission === 'string'
1348
+ ? request.permission
1349
+ : typeof request.action === 'string'
1350
+ ? request.action
1351
+ : '';
1352
+ const metadata = isRecord(request.metadata) ? request.metadata : {};
1353
+ const tool = isRecord(request.tool) ? request.tool : undefined;
1354
+ const callID =
1355
+ typeof request.callID === 'string'
1356
+ ? request.callID
1357
+ : typeof tool?.callID === 'string'
1358
+ ? tool.callID
1359
+ : undefined;
1360
+ const patterns = Array.isArray(request.patterns)
1361
+ ? request.patterns.filter((item): item is string => typeof item === 'string')
1362
+ : typeof request.pattern === 'string'
1363
+ ? [request.pattern]
1364
+ : Array.isArray(request.resources)
1365
+ ? request.resources.filter((item): item is string => typeof item === 'string')
1366
+ : [];
1367
+
1368
+ const decisions: SandboxPermissionDecision[] = [];
1369
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1370
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1371
+
1372
+ if (permission === 'read') {
1373
+ for (const pattern of patterns) {
1374
+ decisions.push(evaluateReadPermission(pattern, config, directory, effectiveAllowRead));
1375
+ }
1376
+ }
1377
+
1378
+ if (permission === 'glob' || permission === 'grep' || permission === 'list') {
1379
+ const searchPath = typeof metadata.path === 'string' ? metadata.path : '.';
1380
+ decisions.push(evaluateReadPermission(searchPath, config, directory, effectiveAllowRead));
1381
+ }
1382
+
1383
+ if (permission === 'edit') {
1384
+ const filepath =
1385
+ typeof metadata.filepath === 'string'
1386
+ ? metadata.filepath
1387
+ : patterns.length === 1
1388
+ ? patterns[0]
1389
+ : undefined;
1390
+ if (filepath) {
1391
+ decisions.push(evaluateWritePermission(filepath, config, directory, effectiveAllowWrite));
1392
+ }
1393
+ }
1394
+
1395
+ if (permission === 'bash') {
1396
+ const command = typeof metadata.command === 'string' ? metadata.command : patterns[0];
1397
+ if (typeof command === 'string' && !config.network.allowNetwork) {
1398
+ for (const domain of extractDomainsFromCommand(command)) {
1399
+ decisions.push(evaluateDomainPermission(domain, config));
1400
+ }
1401
+ }
1402
+ }
1403
+
1404
+ const decision =
1405
+ decisions.find((item) => item.status === 'deny') ??
1406
+ decisions.find((item) => item.status === 'ask');
1407
+ if (!decision) return;
1408
+
1409
+ output.status = decision.status;
1410
+ rememberCallAllowance(callID, decision);
1411
+ },
1412
+
1184
1413
  'tool.execute.before': async (input, output) => {
1185
1414
  if (!isRecord(output.args)) return;
1186
1415
 
@@ -1197,23 +1426,40 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1197
1426
 
1198
1427
  if (input.tool === 'read') {
1199
1428
  const path = getToolPath(output.args);
1200
- if (path) assertReadAllowed(path, config, directory, effectiveAllowRead);
1429
+ if (path)
1430
+ enforcePermission(
1431
+ input.callID,
1432
+ evaluateReadPermission(path, config, directory, effectiveAllowRead),
1433
+ );
1201
1434
  return;
1202
1435
  }
1203
1436
 
1204
1437
  if (input.tool === 'glob' || input.tool === 'grep' || input.tool === 'list') {
1205
- assertReadAllowed(getSearchPath(output.args), config, directory, effectiveAllowRead);
1438
+ enforcePermission(
1439
+ input.callID,
1440
+ evaluateReadPermission(getSearchPath(output.args), config, directory, effectiveAllowRead),
1441
+ );
1206
1442
  return;
1207
1443
  }
1208
1444
 
1209
1445
  if (input.tool === 'write' || input.tool === 'edit') {
1210
1446
  const path = getToolPath(output.args);
1211
- if (path) assertWriteAllowed(path, config, directory, effectiveAllowWrite);
1447
+ if (path)
1448
+ enforcePermission(
1449
+ input.callID,
1450
+ evaluateWritePermission(path, config, directory, effectiveAllowWrite),
1451
+ );
1212
1452
  return;
1213
1453
  }
1214
1454
 
1215
- if (input.tool === 'apply_patch')
1216
- assertApplyPatchAllowed(output.args, config, directory, effectiveAllowWrite);
1455
+ if (input.tool === 'apply_patch' && typeof output.args.patchText === 'string') {
1456
+ for (const path of extractPatchPaths(output.args.patchText)) {
1457
+ enforcePermission(
1458
+ input.callID,
1459
+ evaluateWritePermission(path, config, directory, effectiveAllowWrite),
1460
+ );
1461
+ }
1462
+ }
1217
1463
  },
1218
1464
 
1219
1465
  'shell.env': async (input, output) => {
@@ -1258,31 +1504,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1258
1504
 
1259
1505
  const blockedPath = extractBlockedWritePath(outputText, directory, state.originalCommand);
1260
1506
  if (blockedPath) {
1261
- const config = loadConfig(directory, optionOverrides);
1262
- if (
1263
- !sessionAllowedWritePaths.includes(blockedPath) &&
1264
- !matchesPattern(blockedPath, config.filesystem.allowWrite, directory) &&
1265
- !matchesPattern(blockedPath, config.filesystem.denyWrite, directory)
1266
- ) {
1267
- sessionAllowedWritePaths.push(blockedPath);
1268
- await notifyOnce(
1269
- `write-allow:${blockedPath}`,
1270
- `Write access granted for session: "${blockedPath}"`,
1271
- 'warning',
1272
- );
1273
- }
1274
- if (
1275
- !sessionAllowedReadPaths.includes(blockedPath) &&
1276
- !matchesPattern(blockedPath, config.filesystem.allowRead, directory) &&
1277
- !matchesPattern(blockedPath, config.filesystem.denyRead, directory)
1278
- ) {
1279
- sessionAllowedReadPaths.push(blockedPath);
1280
- await notifyOnce(
1281
- `read-allow:${blockedPath}`,
1282
- `Read access granted for session: "${blockedPath}"`,
1283
- 'warning',
1284
- );
1285
- }
1507
+ await notifyOnce(
1508
+ `blocked:${blockedPath}`,
1509
+ `Sandbox blocked access to "${blockedPath}". Approve the related OpenCode permission prompt and retry if needed.`,
1510
+ 'warning',
1511
+ );
1286
1512
  }
1287
1513
 
1288
1514
  await cleanupBash(input.callID);
@@ -1292,6 +1518,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1292
1518
  if (input.command.trim() === '/sandbox') {
1293
1519
  const config = loadConfig(directory, optionOverrides);
1294
1520
  pushCommandText(input, output, sandboxSummary(config));
1521
+ await client.tui
1522
+ ?.showToast?.({
1523
+ body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
1524
+ })
1525
+ ?.catch?.(() => undefined);
1295
1526
  return;
1296
1527
  }
1297
1528
 
@@ -1310,6 +1541,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1310
1541
  output,
1311
1542
  'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
1312
1543
  );
1544
+ await client.tui
1545
+ ?.showToast?.({
1546
+ body: {
1547
+ title: 'Sandbox',
1548
+ message: 'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
1549
+ variant: 'warning',
1550
+ },
1551
+ })
1552
+ ?.catch?.(() => undefined);
1313
1553
  return;
1314
1554
  }
1315
1555
 
@@ -1323,7 +1563,30 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1323
1563
  return;
1324
1564
  }
1325
1565
  sandboxDisabled = false;
1326
- pushCommandText(input, output, 'Sandbox re-enabled.');
1566
+ const config = await activeConfig();
1567
+ if (!config) {
1568
+ pushCommandText(
1569
+ input,
1570
+ output,
1571
+ 'Sandbox re-enabled but no sandbox.json5 found — no rules active.\nCreate sandbox.json5 to enforce sandboxing.',
1572
+ );
1573
+ await client.tui
1574
+ ?.showToast?.({
1575
+ body: {
1576
+ title: 'Sandbox',
1577
+ message: 'Sandbox re-enabled but no sandbox.json5 found — no rules active.',
1578
+ variant: 'warning',
1579
+ },
1580
+ })
1581
+ ?.catch?.(() => undefined);
1582
+ } else {
1583
+ pushCommandText(input, output, 'Sandbox re-enabled.');
1584
+ await client.tui
1585
+ ?.showToast?.({
1586
+ body: { title: 'Sandbox', message: 'Sandbox re-enabled.', variant: 'success' },
1587
+ })
1588
+ ?.catch?.(() => undefined);
1589
+ }
1327
1590
  return;
1328
1591
  }
1329
1592
 
@@ -1337,17 +1600,60 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1337
1600
  const effectiveAllowWrite = getEffectiveAllowWrite(config);
1338
1601
 
1339
1602
  for (const path of extractCandidatePaths(shellCommand)) {
1340
- assertReadAllowed(path, config, directory, effectiveAllowRead);
1341
- assertWriteAllowed(path, config, directory, effectiveAllowWrite);
1603
+ const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
1604
+ if (readDecision.status === 'deny') {
1605
+ client.tui
1606
+ ?.showToast?.({
1607
+ body: {
1608
+ title: 'Sandbox blocked',
1609
+ message: readDecision.message.slice(0, 120),
1610
+ variant: 'error',
1611
+ },
1612
+ })
1613
+ ?.catch?.(() => undefined);
1614
+ throw errorWithConfigPaths(directory, readDecision.message);
1615
+ }
1616
+
1617
+ const writeDecision = evaluateWritePermission(
1618
+ path,
1619
+ config,
1620
+ directory,
1621
+ effectiveAllowWrite,
1622
+ );
1623
+ if (writeDecision.status === 'deny') {
1624
+ client.tui
1625
+ ?.showToast?.({
1626
+ body: {
1627
+ title: 'Sandbox blocked',
1628
+ message: writeDecision.message.slice(0, 120),
1629
+ variant: 'error',
1630
+ },
1631
+ })
1632
+ ?.catch?.(() => undefined);
1633
+ throw errorWithConfigPaths(directory, writeDecision.message);
1634
+ }
1342
1635
  }
1343
1636
 
1344
1637
  if (!config.network.allowNetwork) {
1345
- const blockedDomain = firstBlockedDomain(shellCommand, config);
1638
+ const effectiveConfig = {
1639
+ ...config,
1640
+ network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1641
+ };
1642
+ const blockedDomain = firstBlockedDomain(shellCommand, effectiveConfig);
1346
1643
  if (blockedDomain) {
1347
1644
  const reason =
1348
1645
  blockedDomain.reason === 'deniedDomains'
1349
1646
  ? 'is blocked by network.deniedDomains'
1350
1647
  : 'is not in network.allowedDomains';
1648
+ client.tui
1649
+ ?.showToast?.({
1650
+ body: {
1651
+ title: 'Sandbox blocked',
1652
+ message: `Network access denied for "${blockedDomain.domain}"`,
1653
+ variant: 'error',
1654
+ },
1655
+ })
1656
+ ?.catch?.(() => undefined);
1351
1657
  throw errorWithConfigPaths(
1352
1658
  directory,
1353
1659
  `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,