opencode-landstrip 0.3.11 → 0.3.13

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 (6) hide show
  1. package/README.md +28 -10
  2. package/index.ts +151 -218
  3. package/package.json +8 -7
  4. package/sandbox.json +7 -47
  5. package/shared.ts +318 -0
  6. package/tui.ts +265 -200
package/README.md CHANGED
@@ -20,7 +20,7 @@ manually:
20
20
  ```json
21
21
  {
22
22
  "$schema": "https://opencode.ai/config.json",
23
- "plugin": ["opencode-landstrip"]
23
+ "plugin": ["opencode-landstrip/tui"]
24
24
  }
25
25
  ```
26
26
 
@@ -29,12 +29,15 @@ manually:
29
29
  This installs `opencode-landstrip` and its `@jarkkojs/landstrip` dependency, which
30
30
  includes platform-specific native binaries for Linux, macOS, and Windows.
31
31
 
32
+ Requires OpenCode `1.17.7` or newer.
33
+
32
34
  On unsupported platforms the plugin loads but leaves sandboxing disabled.
33
35
 
34
36
  ## Configure
35
37
 
36
38
  Create `.opencode/sandbox.json` in a project or
37
- `~/.config/opencode/sandbox.json` globally. Project config takes precedence.
39
+ `~/.config/opencode/sandbox.json` globally. Project config takes precedence and
40
+ array fields are merged with global/default values.
38
41
 
39
42
  See [`sandbox.json`](./sandbox.json) for a starter config.
40
43
 
@@ -42,14 +45,26 @@ See [`sandbox.json`](./sandbox.json) for a starter config.
42
45
 
43
46
  The plugin wraps opencode's AI `bash` tool in `landstrip`, routes proxy-aware
44
47
  network traffic through an allowlist proxy, and blocks read/write tool access
45
- outside configured filesystem allowlists.
46
-
47
- Run `/sandbox` in the TUI to inspect the active sandbox configuration.
48
-
49
- opencode's current server plugin API does not expose Pi-style custom permission
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.
48
+ outside configured filesystem allowlists. The default policy is strict: network
49
+ access is off unless domains are allowed, reads are limited to the project,
50
+ `~/.gitconfig`, and `/dev/null`, and writes are limited to the project and
51
+ `/dev/null`.
52
+
53
+ Run `/sandbox` in the TUI to inspect the active sandbox configuration. A compact
54
+ status badge in the prompt area shows whether the sandbox is active and whether
55
+ network is proxied or open.
56
+
57
+ When OpenCode asks for a sandboxed permission, the TUI plugin plays the host's
58
+ permission sound and desktop notification, then opens a single dialog with
59
+ choices to allow once, allow for the session, persist for the project, persist
60
+ globally, or reject. The dialog shows the exact path or domain being approved.
61
+ Project approvals are written to `.opencode/sandbox.json`; global approvals are
62
+ written to `~/.config/opencode/sandbox.json`.
63
+
64
+ OpenCode's current plugin API allows wrapping AI `bash` tool calls, but does not
65
+ allow a plugin to replace manually typed shell-mode commands with a landstrip
66
+ wrapper. Those commands can still receive the proxy environment from OpenCode,
67
+ but they are not process-sandboxed by this plugin.
53
68
 
54
69
  ## Disable
55
70
 
@@ -66,3 +81,6 @@ Set `enabled` to `false` in `sandbox.json`, or pass plugin options:
66
81
 
67
82
  `opencode-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
68
83
  information.
84
+
85
+ The bundled `@jarkkojs/landstrip` package is licensed separately as
86
+ `Apache-2.0 AND LGPL-2.1-or-later`.
package/index.ts CHANGED
@@ -3,43 +3,23 @@
3
3
 
4
4
  import type { Hooks, Plugin, PluginInput, PluginOptions } from '@opencode-ai/plugin';
5
5
 
6
- import { binaryPath } from '@jarkkojs/landstrip';
7
-
8
6
  import { spawnSync } from 'node:child_process';
9
- import {
10
- existsSync,
11
- mkdtempSync,
12
- readFileSync,
13
- realpathSync,
14
- rmSync,
15
- writeFileSync,
16
- } from 'node:fs';
7
+ import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
17
8
  import { type AddressInfo, connect as connectNet, createServer, type Socket } from 'node:net';
18
9
  import { homedir, tmpdir } from 'node:os';
19
10
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
20
11
  import { URL } from 'node:url';
21
12
 
22
- interface SandboxFilesystemConfig {
23
- denyRead: string[];
24
- allowRead: string[];
25
- allowWrite: string[];
26
- denyWrite: string[];
27
- }
28
-
29
- interface SandboxNetworkConfig {
30
- allowNetwork: boolean;
31
- allowLocalBinding: boolean;
32
- allowAllUnixSockets: boolean;
33
- allowUnixSockets: string[];
34
- allowedDomains: string[];
35
- deniedDomains: string[];
36
- }
37
-
38
- interface SandboxConfig {
39
- enabled: boolean;
40
- network: SandboxNetworkConfig;
41
- filesystem: SandboxFilesystemConfig;
42
- }
13
+ import {
14
+ type SandboxConfig,
15
+ type SandboxFilesystemConfig,
16
+ extractDomainsFromCommand,
17
+ getConfigPaths,
18
+ isRecord,
19
+ landstripBinaryPath,
20
+ loadConfig,
21
+ normalizeOptions,
22
+ } from './shared.js';
43
23
 
44
24
  interface LandstripPolicy {
45
25
  network: {
@@ -53,17 +33,12 @@ interface LandstripPolicy {
53
33
  }
54
34
 
55
35
  interface LandstripErrorResponse {
56
- reason: 'Other' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
36
+ reason: 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
57
37
  file?: string;
38
+ operation?: 'read' | 'write';
58
39
  program?: string;
59
40
  type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
60
- source: string;
61
- }
62
-
63
- interface SandboxConfigOverrides {
64
- enabled?: boolean;
65
- network?: Partial<SandboxNetworkConfig>;
66
- filesystem?: Partial<SandboxFilesystemConfig>;
41
+ source?: string;
67
42
  }
68
43
 
69
44
  interface BashSandboxState {
@@ -85,157 +60,40 @@ interface SandboxPermissionDecision {
85
60
 
86
61
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
87
62
 
88
- const LANDSTRIP_VERSION = [0, 11, 0] as const;
63
+ const LANDSTRIP_VERSION = [0, 11, 9] as const;
64
+ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
65
+ const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorResponse['reason']>([
66
+ 'Other',
67
+ 'AccessDenied',
68
+ 'LaunchFailed',
69
+ 'SetupFailed',
70
+ 'Usage',
71
+ ]);
72
+ const LANDSTRIP_OPERATIONS = new Set<NonNullable<LandstripErrorResponse['operation']>>([
73
+ 'read',
74
+ 'write',
75
+ ]);
76
+ const LANDSTRIP_ERROR_TYPES = new Set<NonNullable<LandstripErrorResponse['type']>>([
77
+ 'filesystem',
78
+ 'network',
79
+ 'platform',
80
+ 'launch',
81
+ 'encoding',
82
+ ]);
89
83
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
90
84
 
91
- const DEFAULT_CONFIG: SandboxConfig = {
92
- enabled: true,
93
- network: {
94
- allowNetwork: false,
95
- allowLocalBinding: false,
96
- allowAllUnixSockets: false,
97
- allowUnixSockets: [],
98
- allowedDomains: [
99
- 'npmjs.org',
100
- '*.npmjs.org',
101
- 'registry.npmjs.org',
102
- 'registry.yarnpkg.com',
103
- 'pypi.org',
104
- '*.pypi.org',
105
- 'github.com',
106
- '*.github.com',
107
- 'api.github.com',
108
- 'raw.githubusercontent.com',
109
- 'crates.io',
110
- '*.crates.io',
111
- 'static.crates.io',
112
- ],
113
- deniedDomains: [],
114
- },
115
- filesystem: {
116
- denyRead: ['/Users', '/home'],
117
- allowRead: [
118
- '.',
119
- '/dev/null',
120
- '~/.config/opencode',
121
- '~/.config/git',
122
- '~/.gitconfig',
123
- '~/.local',
124
- '~/.cargo',
125
- ],
126
- allowWrite: ['.', '/tmp', '/dev/null'],
127
- denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
128
- },
129
- };
130
-
131
- function isRecord(value: unknown): value is Record<string, unknown> {
132
- return typeof value === 'object' && value !== null && !Array.isArray(value);
133
- }
134
-
135
- function stringArray(value: unknown): string[] | undefined {
136
- if (!Array.isArray(value)) return undefined;
137
- return value.every((item) => typeof item === 'string') ? [...value] : undefined;
138
- }
139
-
140
- function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
141
- if (!isRecord(value)) return undefined;
142
-
143
- const config: Partial<SandboxNetworkConfig> = {};
144
- if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
145
- if (typeof value.allowLocalBinding === 'boolean')
146
- config.allowLocalBinding = value.allowLocalBinding;
147
- if (typeof value.allowAllUnixSockets === 'boolean')
148
- config.allowAllUnixSockets = value.allowAllUnixSockets;
149
-
150
- const allowUnixSockets = stringArray(value.allowUnixSockets);
151
- if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
152
-
153
- const allowedDomains = stringArray(value.allowedDomains);
154
- if (allowedDomains) config.allowedDomains = allowedDomains;
155
-
156
- const deniedDomains = stringArray(value.deniedDomains);
157
- if (deniedDomains) config.deniedDomains = deniedDomains;
158
-
159
- return config;
160
- }
161
-
162
- function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
163
- if (!isRecord(value)) return undefined;
164
-
165
- const config: Partial<SandboxFilesystemConfig> = {};
166
- const denyRead = stringArray(value.denyRead);
167
- if (denyRead) config.denyRead = denyRead;
168
-
169
- const allowRead = stringArray(value.allowRead);
170
- if (allowRead) config.allowRead = allowRead;
171
-
172
- const allowWrite = stringArray(value.allowWrite);
173
- if (allowWrite) config.allowWrite = allowWrite;
174
-
175
- const denyWrite = stringArray(value.denyWrite);
176
- if (denyWrite) config.denyWrite = denyWrite;
177
-
178
- return config;
179
- }
180
-
181
- function normalizeConfig(value: unknown): SandboxConfigOverrides {
182
- if (!isRecord(value)) return {};
183
-
184
- const config: SandboxConfigOverrides = {};
185
- if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
186
-
187
- const network = normalizeNetworkConfig(value.network);
188
- if (network) config.network = network;
189
-
190
- const filesystem = normalizeFilesystemConfig(value.filesystem);
191
- if (filesystem) config.filesystem = filesystem;
192
-
193
- return config;
194
- }
195
-
196
- function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOverrides {
197
- if (!isRecord(options)) return {};
198
- return normalizeConfig(isRecord(options.config) ? options.config : options);
199
- }
200
-
201
- function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
202
- return {
203
- enabled: overrides.enabled ?? base.enabled,
204
- network: {
205
- ...base.network,
206
- ...overrides.network,
207
- },
208
- filesystem: {
209
- ...base.filesystem,
210
- ...overrides.filesystem,
211
- },
212
- };
213
- }
214
-
215
- function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
216
- return {
217
- globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
218
- projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
219
- };
85
+ function isLandstripErrorReason(value: string): value is LandstripErrorResponse['reason'] {
86
+ return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorResponse['reason']);
220
87
  }
221
88
 
222
- function readConfigFile(configPath: string): SandboxConfigOverrides {
223
- if (!existsSync(configPath)) return {};
224
-
225
- try {
226
- return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
227
- } catch (error) {
228
- console.error(`Warning: Could not parse ${configPath}: ${error}`);
229
- return {};
230
- }
89
+ function isLandstripOperation(
90
+ value: string,
91
+ ): value is NonNullable<LandstripErrorResponse['operation']> {
92
+ return LANDSTRIP_OPERATIONS.has(value as NonNullable<LandstripErrorResponse['operation']>);
231
93
  }
232
94
 
233
- function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
234
- const { globalPath, projectPath } = getConfigPaths(baseDirectory);
235
- return deepMerge(
236
- deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
237
- optionOverrides,
238
- );
95
+ function isLandstripErrorType(value: string): value is NonNullable<LandstripErrorResponse['type']> {
96
+ return LANDSTRIP_ERROR_TYPES.has(value as NonNullable<LandstripErrorResponse['type']>);
239
97
  }
240
98
 
241
99
  function expandPath(filePath: string, baseDirectory: string): string {
@@ -318,18 +176,6 @@ function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory:
318
176
  return allowWrite.length === 0 || !matchesPattern(path, allowWrite, baseDirectory);
319
177
  }
320
178
 
321
- function extractDomainsFromCommand(command: string): string[] {
322
- const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
323
- const domains = new Set<string>();
324
- let match: RegExpExecArray | null;
325
-
326
- while ((match = urlRegex.exec(command)) !== null) {
327
- domains.add(match[1]);
328
- }
329
-
330
- return [...domains];
331
- }
332
-
333
179
  function domainMatchesPattern(domain: string, pattern: string): boolean {
334
180
  const normalizedDomain = domain.toLowerCase();
335
181
  const normalizedPattern = pattern.toLowerCase();
@@ -527,7 +373,7 @@ function evaluateDomainPermission(
527
373
  }
528
374
 
529
375
  function landstripVersion(): string | null {
530
- const result = spawnSync(binaryPath(), ['--version'], { encoding: 'utf-8' });
376
+ const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
531
377
  if (result.status !== 0) return null;
532
378
  return result.stdout.trim();
533
379
  }
@@ -564,25 +410,19 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
564
410
  if (key.length > 0 && value.length > 0) fields[key] = value;
565
411
  }
566
412
 
567
- if (
568
- fields.reason &&
569
- ['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
570
- fields.source
571
- ) {
413
+ if (fields.reason && isLandstripErrorReason(fields.reason)) {
572
414
  const error: LandstripErrorResponse = {
573
- reason: fields.reason as LandstripErrorResponse['reason'],
574
- source: fields.source,
415
+ reason: fields.reason,
575
416
  };
576
417
 
577
418
  if (fields.file) error.file = fields.file;
419
+ if (fields.operation && isLandstripOperation(fields.operation)) {
420
+ error.operation = fields.operation;
421
+ }
578
422
  if (fields.program) error.program = fields.program;
423
+ if (fields.source) error.source = fields.source;
579
424
 
580
- if (
581
- fields.type &&
582
- ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
583
- ) {
584
- error.type = fields.type as LandstripErrorResponse['type'];
585
- }
425
+ if (fields.type && isLandstripErrorType(fields.type)) error.type = fields.type;
586
426
 
587
427
  errors.push(error);
588
428
  }
@@ -599,13 +439,16 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
599
439
  if (err.file) {
600
440
  parts.push(` (${err.file})`);
601
441
  }
442
+ if (err.operation) {
443
+ parts.push(` ${err.operation}`);
444
+ }
602
445
  if (err.program) {
603
446
  parts.push(` ${err.program}`);
604
447
  }
605
448
  if (err.type) {
606
449
  parts.push(`:${err.type}`);
607
450
  }
608
- parts.push(`: ${err.source}`);
451
+ if (err.source) parts.push(`: ${err.source}`);
609
452
 
610
453
  return parts.join('');
611
454
  })
@@ -838,12 +681,12 @@ function shellArgs(shell: string, command: string): string[] {
838
681
  function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
839
682
  const args = ['-p', policyPath, ...shellArgs(shell, command)];
840
683
 
841
- return [binaryPath(), ...args].map(shellQuote).join(' ');
684
+ return [landstripBinaryPath(), ...args].map(shellQuote).join(' ');
842
685
  }
843
686
 
844
687
  function isGeneratedWrappedCommand(command: string): boolean {
845
688
  return (
846
- command.startsWith(`${shellQuote(binaryPath())} `) &&
689
+ command.startsWith(`${shellQuote(landstripBinaryPath())} `) &&
847
690
  command.includes(` ${shellQuote('-p')} `) &&
848
691
  command.includes('opencode-landstrip-')
849
692
  );
@@ -964,6 +807,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
964
807
 
965
808
  function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
966
809
  if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
810
+ client.tui
811
+ ?.showToast?.({
812
+ body: {
813
+ title: 'Sandbox blocked',
814
+ message: decision.message.slice(0, 120),
815
+ variant: 'error',
816
+ },
817
+ })
818
+ ?.catch?.(() => undefined);
967
819
  throw errorWithConfigPaths(directory, decision.message);
968
820
  }
969
821
 
@@ -995,7 +847,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
995
847
  '# Sandbox Configuration',
996
848
  '',
997
849
  `Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
998
- `landstrip: ${binaryPath()}`,
850
+ `landstrip package binary: ${landstripBinaryPath()}`,
999
851
  '',
1000
852
  'Config files:',
1001
853
  `- project: ${projectPath}`,
@@ -1082,7 +934,17 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1082
934
  return landstripCheck;
1083
935
  }
1084
936
 
1085
- const version = landstripVersion();
937
+ let version: string | null;
938
+ try {
939
+ version = landstripVersion();
940
+ } catch (error) {
941
+ landstripCheck = {
942
+ ok: false,
943
+ reason: error instanceof Error ? error.message : String(error),
944
+ };
945
+ return landstripCheck;
946
+ }
947
+
1086
948
  if (!version) {
1087
949
  landstripCheck = {
1088
950
  ok: false,
@@ -1094,7 +956,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1094
956
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
1095
957
  landstripCheck = {
1096
958
  ok: false,
1097
- reason: `landstrip 0.11.0 or newer is required; found: ${version}`,
959
+ reason: `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
1098
960
  };
1099
961
  return landstripCheck;
1100
962
  }
@@ -1107,7 +969,14 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1107
969
  if (sandboxDisabled) return null;
1108
970
 
1109
971
  const config = loadConfig(directory, optionOverrides);
1110
- if (!config.enabled) return null;
972
+ if (!config.enabled) {
973
+ await notifyOnce(
974
+ `not-configured:${directory}`,
975
+ 'Sandbox is not configured — no sandbox.json5 found',
976
+ 'info',
977
+ );
978
+ return null;
979
+ }
1111
980
 
1112
981
  const check = checkLandstrip();
1113
982
  if (!check?.ok) {
@@ -1435,6 +1304,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1435
1304
  if (input.command.trim() === '/sandbox') {
1436
1305
  const config = loadConfig(directory, optionOverrides);
1437
1306
  pushCommandText(input, output, sandboxSummary(config));
1307
+ await client.tui
1308
+ ?.showToast?.({
1309
+ body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
1310
+ })
1311
+ ?.catch?.(() => undefined);
1438
1312
  return;
1439
1313
  }
1440
1314
 
@@ -1453,6 +1327,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1453
1327
  output,
1454
1328
  'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
1455
1329
  );
1330
+ await client.tui
1331
+ ?.showToast?.({
1332
+ body: {
1333
+ title: 'Sandbox',
1334
+ message: 'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
1335
+ variant: 'warning',
1336
+ },
1337
+ })
1338
+ ?.catch?.(() => undefined);
1456
1339
  return;
1457
1340
  }
1458
1341
 
@@ -1466,7 +1349,30 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1466
1349
  return;
1467
1350
  }
1468
1351
  sandboxDisabled = false;
1469
- pushCommandText(input, output, 'Sandbox re-enabled.');
1352
+ const config = await activeConfig();
1353
+ if (!config) {
1354
+ pushCommandText(
1355
+ input,
1356
+ output,
1357
+ 'Sandbox re-enabled but no sandbox.json5 found — no rules active.\nCreate sandbox.json5 to enforce sandboxing.',
1358
+ );
1359
+ await client.tui
1360
+ ?.showToast?.({
1361
+ body: {
1362
+ title: 'Sandbox',
1363
+ message: 'Sandbox re-enabled but no sandbox.json5 found — no rules active.',
1364
+ variant: 'warning',
1365
+ },
1366
+ })
1367
+ ?.catch?.(() => undefined);
1368
+ } else {
1369
+ pushCommandText(input, output, 'Sandbox re-enabled.');
1370
+ await client.tui
1371
+ ?.showToast?.({
1372
+ body: { title: 'Sandbox', message: 'Sandbox re-enabled.', variant: 'success' },
1373
+ })
1374
+ ?.catch?.(() => undefined);
1375
+ }
1470
1376
  return;
1471
1377
  }
1472
1378
 
@@ -1482,6 +1388,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1482
1388
  for (const path of extractCandidatePaths(shellCommand)) {
1483
1389
  const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
1484
1390
  if (readDecision.status === 'deny') {
1391
+ client.tui
1392
+ ?.showToast?.({
1393
+ body: {
1394
+ title: 'Sandbox blocked',
1395
+ message: readDecision.message.slice(0, 120),
1396
+ variant: 'error',
1397
+ },
1398
+ })
1399
+ ?.catch?.(() => undefined);
1485
1400
  throw errorWithConfigPaths(directory, readDecision.message);
1486
1401
  }
1487
1402
 
@@ -1492,6 +1407,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1492
1407
  effectiveAllowWrite,
1493
1408
  );
1494
1409
  if (writeDecision.status === 'deny') {
1410
+ client.tui
1411
+ ?.showToast?.({
1412
+ body: {
1413
+ title: 'Sandbox blocked',
1414
+ message: writeDecision.message.slice(0, 120),
1415
+ variant: 'error',
1416
+ },
1417
+ })
1418
+ ?.catch?.(() => undefined);
1495
1419
  throw errorWithConfigPaths(directory, writeDecision.message);
1496
1420
  }
1497
1421
  }
@@ -1507,6 +1431,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1507
1431
  blockedDomain.reason === 'deniedDomains'
1508
1432
  ? 'is blocked by network.deniedDomains'
1509
1433
  : 'is not in network.allowedDomains';
1434
+ client.tui
1435
+ ?.showToast?.({
1436
+ body: {
1437
+ title: 'Sandbox blocked',
1438
+ message: `Network access denied for "${blockedDomain.domain}"`,
1439
+ variant: 'error',
1440
+ },
1441
+ })
1442
+ ?.catch?.(() => undefined);
1510
1443
  throw errorWithConfigPaths(
1511
1444
  directory,
1512
1445
  `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
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
+ "shared.ts",
19
20
  "landstrip.d.ts",
20
21
  "README.md",
21
22
  "sandbox.json"
@@ -37,8 +38,8 @@
37
38
  }
38
39
  },
39
40
  "scripts": {
40
- "fmt": "oxfmt index.ts tui.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
41
- "lint": "oxlint index.ts tui.ts",
41
+ "fmt": "oxfmt index.ts tui.ts shared.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
42
+ "lint": "oxlint index.ts tui.ts shared.ts",
42
43
  "check": "tsc --noEmit",
43
44
  "test": "node --test test/*.test.mjs",
44
45
  "all": "npm run fmt && npm run lint && npm run check && npm test",
@@ -48,10 +49,10 @@
48
49
  "ci:test": "npm test"
49
50
  },
50
51
  "dependencies": {
51
- "@jarkkojs/landstrip": "^0.11.0"
52
+ "@jarkkojs/landstrip": "^0.11.11"
52
53
  },
53
54
  "devDependencies": {
54
- "@opencode-ai/plugin": "^1.17.3",
55
+ "@opencode-ai/plugin": "^1.17.7",
55
56
  "@opentui/core": ">=0.3.4",
56
57
  "@opentui/keymap": ">=0.3.4",
57
58
  "@opentui/solid": ">=0.3.4",
@@ -61,7 +62,7 @@
61
62
  "typescript": "^5.8.2"
62
63
  },
63
64
  "peerDependencies": {
64
- "@opencode-ai/plugin": "^1.17.3"
65
+ "@opencode-ai/plugin": "^1.17.7"
65
66
  },
66
67
  "peerDependenciesMeta": {
67
68
  "@opencode-ai/plugin": {
@@ -69,6 +70,6 @@
69
70
  }
70
71
  },
71
72
  "engines": {
72
- "opencode": ">=1.17.3"
73
+ "opencode": ">=1.17.7"
73
74
  }
74
75
  }