opencode-landstrip 0.3.12 → 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 (5) hide show
  1. package/README.md +11 -5
  2. package/index.ts +11 -225
  3. package/package.json +7 -6
  4. package/shared.ts +318 -0
  5. package/tui.ts +171 -328
package/README.md CHANGED
@@ -29,6 +29,8 @@ 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
@@ -48,12 +50,16 @@ access is off unless domains are allowed, reads are limited to the project,
48
50
  `~/.gitconfig`, and `/dev/null`, and writes are limited to the project and
49
51
  `/dev/null`.
50
52
 
51
- Run `/sandbox` in the TUI to inspect the active sandbox configuration.
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.
52
56
 
53
- When OpenCode asks for a sandboxed permission, the TUI plugin adds choices to
54
- allow once, allow for the session, persist for the project, persist globally, or
55
- reject. Project approvals are written to `.opencode/sandbox.json`; global
56
- approvals are written to `~/.config/opencode/sandbox.json`.
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`.
57
63
 
58
64
  OpenCode's current plugin API allows wrapping AI `bash` tool calls, but does not
59
65
  allow a plugin to replace manually typed shell-mode commands with a landstrip
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: {
@@ -61,12 +41,6 @@ interface LandstripErrorResponse {
61
41
  source?: string;
62
42
  }
63
43
 
64
- interface SandboxConfigOverrides {
65
- enabled?: boolean;
66
- network?: Partial<SandboxNetworkConfig>;
67
- filesystem?: Partial<SandboxFilesystemConfig>;
68
- }
69
-
70
44
  interface BashSandboxState {
71
45
  originalCommand: string;
72
46
  wrappedCommand: string;
@@ -107,13 +81,6 @@ const LANDSTRIP_ERROR_TYPES = new Set<NonNullable<LandstripErrorResponse['type']
107
81
  'encoding',
108
82
  ]);
109
83
  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
84
 
118
85
  function isLandstripErrorReason(value: string): value is LandstripErrorResponse['reason'] {
119
86
  return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorResponse['reason']);
@@ -129,148 +96,6 @@ function isLandstripErrorType(value: string): value is NonNullable<LandstripErro
129
96
  return LANDSTRIP_ERROR_TYPES.has(value as NonNullable<LandstripErrorResponse['type']>);
130
97
  }
131
98
 
132
- const DEFAULT_CONFIG: SandboxConfig = {
133
- enabled: true,
134
- network: {
135
- allowNetwork: false,
136
- allowLocalBinding: false,
137
- allowAllUnixSockets: false,
138
- allowUnixSockets: [],
139
- allowedDomains: [],
140
- deniedDomains: [],
141
- },
142
- filesystem: {
143
- denyRead: ['/Users', '/home'],
144
- allowRead: ['.', '~/.gitconfig', '/dev/null'],
145
- allowWrite: ['.', '/dev/null'],
146
- denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
147
- },
148
- };
149
-
150
- function isRecord(value: unknown): value is Record<string, unknown> {
151
- return typeof value === 'object' && value !== null && !Array.isArray(value);
152
- }
153
-
154
- function stringArray(value: unknown): string[] | undefined {
155
- if (!Array.isArray(value)) return undefined;
156
- return value.every((item) => typeof item === 'string') ? [...value] : undefined;
157
- }
158
-
159
- function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
160
- if (!isRecord(value)) return undefined;
161
-
162
- const config: Partial<SandboxNetworkConfig> = {};
163
- if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
164
- if (typeof value.allowLocalBinding === 'boolean')
165
- config.allowLocalBinding = value.allowLocalBinding;
166
- if (typeof value.allowAllUnixSockets === 'boolean')
167
- config.allowAllUnixSockets = value.allowAllUnixSockets;
168
-
169
- const allowUnixSockets = stringArray(value.allowUnixSockets);
170
- if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
171
-
172
- const allowedDomains = stringArray(value.allowedDomains);
173
- if (allowedDomains) config.allowedDomains = allowedDomains;
174
-
175
- const deniedDomains = stringArray(value.deniedDomains);
176
- if (deniedDomains) config.deniedDomains = deniedDomains;
177
-
178
- return config;
179
- }
180
-
181
- function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
182
- if (!isRecord(value)) return undefined;
183
-
184
- const config: Partial<SandboxFilesystemConfig> = {};
185
- const denyRead = stringArray(value.denyRead);
186
- if (denyRead) config.denyRead = denyRead;
187
-
188
- const allowRead = stringArray(value.allowRead);
189
- if (allowRead) config.allowRead = allowRead;
190
-
191
- const allowWrite = stringArray(value.allowWrite);
192
- if (allowWrite) config.allowWrite = allowWrite;
193
-
194
- const denyWrite = stringArray(value.denyWrite);
195
- if (denyWrite) config.denyWrite = denyWrite;
196
-
197
- return config;
198
- }
199
-
200
- function normalizeConfig(value: unknown): SandboxConfigOverrides {
201
- if (!isRecord(value)) return {};
202
-
203
- const config: SandboxConfigOverrides = {};
204
- if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
205
-
206
- const network = normalizeNetworkConfig(value.network);
207
- if (network) config.network = network;
208
-
209
- const filesystem = normalizeFilesystemConfig(value.filesystem);
210
- if (filesystem) config.filesystem = filesystem;
211
-
212
- return config;
213
- }
214
-
215
- function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOverrides {
216
- if (!isRecord(options)) return {};
217
- return normalizeConfig(isRecord(options.config) ? options.config : options);
218
- }
219
-
220
- function mergeArray(base: string[], override?: string[]): string[] {
221
- if (!override) return base;
222
- return [...new Set([...base, ...override])];
223
- }
224
-
225
- function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
226
- const network = overrides.network;
227
- const filesystem = overrides.filesystem;
228
-
229
- return {
230
- enabled: overrides.enabled ?? base.enabled,
231
- 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),
238
- },
239
- 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),
244
- },
245
- };
246
- }
247
-
248
- function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
249
- return {
250
- globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
251
- projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
252
- };
253
- }
254
-
255
- function readConfigFile(configPath: string): SandboxConfigOverrides {
256
- if (!existsSync(configPath)) return {};
257
-
258
- try {
259
- return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
260
- } catch (error) {
261
- console.error(`Warning: Could not parse ${configPath}: ${error}`);
262
- return {};
263
- }
264
- }
265
-
266
- function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
267
- const { globalPath, projectPath } = getConfigPaths(baseDirectory);
268
- return deepMerge(
269
- deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
270
- optionOverrides,
271
- );
272
- }
273
-
274
99
  function expandPath(filePath: string, baseDirectory: string): string {
275
100
  const expanded = filePath.replace(/^~(?=$|[/])/, homedir());
276
101
  return resolve(isAbsolute(expanded) ? expanded : join(baseDirectory, expanded));
@@ -281,33 +106,6 @@ function configuredShellPath(config: unknown): string | undefined {
281
106
  return typeof config.shell === 'string' ? config.shell : undefined;
282
107
  }
283
108
 
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
-
311
109
  function canonicalizePath(filePath: string, baseDirectory: string): string {
312
110
  const abs = expandPath(filePath, baseDirectory);
313
111
 
@@ -378,18 +176,6 @@ function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory:
378
176
  return allowWrite.length === 0 || !matchesPattern(path, allowWrite, baseDirectory);
379
177
  }
380
178
 
381
- function extractDomainsFromCommand(command: string): string[] {
382
- const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
383
- const domains = new Set<string>();
384
- let match: RegExpExecArray | null;
385
-
386
- while ((match = urlRegex.exec(command)) !== null) {
387
- domains.add(match[1]);
388
- }
389
-
390
- return [...domains];
391
- }
392
-
393
179
  function domainMatchesPattern(domain: string, pattern: string): boolean {
394
180
  const normalizedDomain = domain.toLowerCase();
395
181
  const normalizedPattern = pattern.toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.3.12",
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",
@@ -51,7 +52,7 @@
51
52
  "@jarkkojs/landstrip": "^0.11.11"
52
53
  },
53
54
  "devDependencies": {
54
- "@opencode-ai/plugin": "^1.17.6",
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.6"
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.6"
73
+ "opencode": ">=1.17.7"
73
74
  }
74
75
  }
package/shared.ts ADDED
@@ -0,0 +1,318 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) Jarkko Sakkinen 2026
3
+
4
+ import { binaryPath } from '@jarkkojs/landstrip';
5
+
6
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ export interface SandboxFilesystemConfig {
11
+ denyRead: string[];
12
+ allowRead: string[];
13
+ allowWrite: string[];
14
+ denyWrite: string[];
15
+ }
16
+
17
+ export interface SandboxNetworkConfig {
18
+ allowNetwork: boolean;
19
+ allowLocalBinding: boolean;
20
+ allowAllUnixSockets: boolean;
21
+ allowUnixSockets: string[];
22
+ allowedDomains: string[];
23
+ deniedDomains: string[];
24
+ }
25
+
26
+ export interface SandboxConfig {
27
+ enabled: boolean;
28
+ network: SandboxNetworkConfig;
29
+ filesystem: SandboxFilesystemConfig;
30
+ }
31
+
32
+ export interface SandboxConfigOverrides {
33
+ enabled?: boolean;
34
+ network?: Partial<SandboxNetworkConfig>;
35
+ filesystem?: Partial<SandboxFilesystemConfig>;
36
+ }
37
+
38
+ export const DEFAULT_CONFIG: SandboxConfig = {
39
+ enabled: true,
40
+ network: {
41
+ allowNetwork: false,
42
+ allowLocalBinding: false,
43
+ allowAllUnixSockets: false,
44
+ allowUnixSockets: [],
45
+ allowedDomains: [],
46
+ deniedDomains: [],
47
+ },
48
+ filesystem: {
49
+ denyRead: ['/Users', '/home'],
50
+ allowRead: ['.', '~/.gitconfig', '/dev/null'],
51
+ allowWrite: ['.', '/dev/null'],
52
+ denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
53
+ },
54
+ };
55
+
56
+ const LANDSTRIP_PACKAGE_NAMES = new Set([
57
+ '@jarkkojs/landstrip',
58
+ '@jarkkojs/landstrip-darwin-arm64',
59
+ '@jarkkojs/landstrip-darwin-x64',
60
+ '@jarkkojs/landstrip-linux-x64',
61
+ '@jarkkojs/landstrip-win32-x64',
62
+ ]);
63
+
64
+ export function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ function stringArray(value: unknown): string[] | undefined {
69
+ if (!Array.isArray(value)) return undefined;
70
+ return value.every((item) => typeof item === 'string') ? [...value] : undefined;
71
+ }
72
+
73
+ function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
74
+ if (!isRecord(value)) return undefined;
75
+
76
+ const config: Partial<SandboxNetworkConfig> = {};
77
+ if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
78
+ if (typeof value.allowLocalBinding === 'boolean')
79
+ config.allowLocalBinding = value.allowLocalBinding;
80
+ if (typeof value.allowAllUnixSockets === 'boolean')
81
+ config.allowAllUnixSockets = value.allowAllUnixSockets;
82
+
83
+ const allowUnixSockets = stringArray(value.allowUnixSockets);
84
+ if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
85
+
86
+ const allowedDomains = stringArray(value.allowedDomains);
87
+ if (allowedDomains) config.allowedDomains = allowedDomains;
88
+
89
+ const deniedDomains = stringArray(value.deniedDomains);
90
+ if (deniedDomains) config.deniedDomains = deniedDomains;
91
+
92
+ return config;
93
+ }
94
+
95
+ function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
96
+ if (!isRecord(value)) return undefined;
97
+
98
+ const config: Partial<SandboxFilesystemConfig> = {};
99
+ const denyRead = stringArray(value.denyRead);
100
+ if (denyRead) config.denyRead = denyRead;
101
+
102
+ const allowRead = stringArray(value.allowRead);
103
+ if (allowRead) config.allowRead = allowRead;
104
+
105
+ const allowWrite = stringArray(value.allowWrite);
106
+ if (allowWrite) config.allowWrite = allowWrite;
107
+
108
+ const denyWrite = stringArray(value.denyWrite);
109
+ if (denyWrite) config.denyWrite = denyWrite;
110
+
111
+ return config;
112
+ }
113
+
114
+ export function normalizeConfig(value: unknown): SandboxConfigOverrides {
115
+ if (!isRecord(value)) return {};
116
+
117
+ const config: SandboxConfigOverrides = {};
118
+ if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
119
+
120
+ const network = normalizeNetworkConfig(value.network);
121
+ if (network) config.network = network;
122
+
123
+ const filesystem = normalizeFilesystemConfig(value.filesystem);
124
+ if (filesystem) config.filesystem = filesystem;
125
+
126
+ return config;
127
+ }
128
+
129
+ export function normalizeOptions(options: unknown): SandboxConfigOverrides {
130
+ if (!isRecord(options)) return {};
131
+ return normalizeConfig(isRecord(options.config) ? options.config : options);
132
+ }
133
+
134
+ function mergeArray(base: string[], override?: string[]): string[] {
135
+ if (!override) return base;
136
+ return [...new Set([...base, ...override])];
137
+ }
138
+
139
+ export function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
140
+ const network = overrides.network;
141
+ const filesystem = overrides.filesystem;
142
+
143
+ return {
144
+ enabled: overrides.enabled ?? base.enabled,
145
+ network: {
146
+ allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
147
+ allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
148
+ allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
149
+ allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
150
+ allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
151
+ deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
152
+ },
153
+ filesystem: {
154
+ denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
155
+ allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
156
+ allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
157
+ denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
158
+ },
159
+ };
160
+ }
161
+
162
+ export function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
163
+ return {
164
+ globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
165
+ projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
166
+ };
167
+ }
168
+
169
+ // Returns `{}` when the file is absent and `null` when it exists but cannot be
170
+ // parsed, so callers can refuse to overwrite a corrupted config.
171
+ export function readConfigFile(configPath: string): SandboxConfigOverrides | null {
172
+ if (!existsSync(configPath)) return {};
173
+
174
+ try {
175
+ return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ export function loadConfig(
182
+ baseDirectory: string,
183
+ optionOverrides: SandboxConfigOverrides,
184
+ ): SandboxConfig {
185
+ const { globalPath, projectPath } = getConfigPaths(baseDirectory);
186
+ return deepMerge(
187
+ deepMerge(
188
+ deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath) ?? {}),
189
+ readConfigFile(projectPath) ?? {},
190
+ ),
191
+ optionOverrides,
192
+ );
193
+ }
194
+
195
+ export function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
196
+ const current = readConfigFile(configPath);
197
+ if (current === null) {
198
+ throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
199
+ }
200
+
201
+ const next = deepMerge(deepMerge(DEFAULT_CONFIG, current), update);
202
+
203
+ mkdirSync(dirname(configPath), { recursive: true });
204
+ writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
205
+ }
206
+
207
+ export function landstripBinaryPath(): string {
208
+ const filePath = realpathSync.native(binaryPath());
209
+ let probe = dirname(filePath);
210
+
211
+ while (true) {
212
+ const manifestPath = join(probe, 'package.json');
213
+ if (existsSync(manifestPath)) {
214
+ try {
215
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
216
+ if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
217
+ return filePath;
218
+ }
219
+ } catch {
220
+ // malformed package.json — continue walking to parent
221
+ }
222
+ }
223
+
224
+ const parent = dirname(probe);
225
+ if (parent === probe) break;
226
+ probe = parent;
227
+ }
228
+
229
+ throw new Error(
230
+ `Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
231
+ );
232
+ }
233
+
234
+ export function extractDomainsFromCommand(command: string): string[] {
235
+ const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
236
+ const domains = new Set<string>();
237
+ let match: RegExpExecArray | null;
238
+
239
+ while ((match = urlRegex.exec(command)) !== null) {
240
+ domains.add(match[1]);
241
+ }
242
+
243
+ return [...domains];
244
+ }
245
+
246
+ // Permission requests reach the plugin in slightly different shapes across the
247
+ // server hook and the TUI event bus, so the field-fallback parsing below is the
248
+ // single source of truth both entrypoints share.
249
+ export function permissionType(permission: Record<string, unknown>, fallback = ''): string {
250
+ if (typeof permission.permission === 'string') return permission.permission;
251
+ if (typeof permission.action === 'string') return permission.action;
252
+ if (typeof permission.type === 'string') return permission.type;
253
+ return fallback;
254
+ }
255
+
256
+ export function permissionPattern(permission: Record<string, unknown>): string | undefined {
257
+ const patterns = permission.patterns;
258
+ if (Array.isArray(patterns))
259
+ return patterns.find((item): item is string => typeof item === 'string');
260
+
261
+ const pattern = permission.pattern;
262
+ if (typeof pattern === 'string') return pattern;
263
+ if (Array.isArray(pattern))
264
+ return pattern.find((item): item is string => typeof item === 'string');
265
+
266
+ return undefined;
267
+ }
268
+
269
+ export function permissionLabel(permission: Record<string, unknown>): string {
270
+ const type = permissionType(permission, 'permission');
271
+ const title = typeof permission.title === 'string' ? permission.title : type;
272
+ const pattern = permissionPattern(permission);
273
+ return pattern ? `${title}: ${pattern}` : title;
274
+ }
275
+
276
+ // The concrete resource a permission concerns (a path or a domain), used to show
277
+ // the user exactly what they are approving and to persist the right allowlist.
278
+ export function permissionResource(permission: Record<string, unknown>): string | undefined {
279
+ const metadata = isRecord(permission.metadata) ? permission.metadata : {};
280
+ const type = permissionType(permission);
281
+ const pattern = permissionPattern(permission);
282
+
283
+ if (type === 'bash') {
284
+ const command = typeof metadata.command === 'string' ? metadata.command : pattern;
285
+ const domains = typeof command === 'string' ? extractDomainsFromCommand(command) : [];
286
+ return domains.length > 0 ? domains.join(', ') : (command ?? pattern);
287
+ }
288
+
289
+ if (typeof metadata.filepath === 'string') return metadata.filepath;
290
+ if (typeof metadata.path === 'string') return metadata.path;
291
+ return pattern;
292
+ }
293
+
294
+ export function updateForPermission(
295
+ permission: Record<string, unknown>,
296
+ ): SandboxConfigOverrides | null {
297
+ const metadata = isRecord(permission.metadata) ? permission.metadata : {};
298
+ const type = permissionType(permission);
299
+ const pattern = permissionPattern(permission);
300
+
301
+ if (type === 'bash') {
302
+ const command = typeof metadata.command === 'string' ? metadata.command : pattern;
303
+ const domains = typeof command === 'string' ? extractDomainsFromCommand(command) : [];
304
+ return domains.length > 0 ? { network: { allowedDomains: domains } } : null;
305
+ }
306
+
307
+ if (type === 'read' || type === 'glob' || type === 'grep' || type === 'list') {
308
+ const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
309
+ return filePath ? { filesystem: { allowRead: [filePath] } } : null;
310
+ }
311
+
312
+ if (type === 'edit' || type === 'write' || type === 'apply_patch') {
313
+ const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
314
+ return filePath ? { filesystem: { allowWrite: [filePath] } } : null;
315
+ }
316
+
317
+ return null;
318
+ }
package/tui.ts CHANGED
@@ -1,230 +1,35 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
- import type { TuiPlugin } from '@opencode-ai/plugin/tui';
5
-
6
- import { binaryPath } from '@jarkkojs/landstrip';
7
-
8
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
9
- import { homedir } from 'node:os';
10
- import { dirname, join } from 'node:path';
11
-
12
- interface SandboxFilesystemConfig {
13
- denyRead: string[];
14
- allowRead: string[];
15
- allowWrite: string[];
16
- denyWrite: string[];
17
- }
18
-
19
- interface SandboxNetworkConfig {
20
- allowNetwork: boolean;
21
- allowLocalBinding: boolean;
22
- allowAllUnixSockets: boolean;
23
- allowUnixSockets: string[];
24
- allowedDomains: string[];
25
- deniedDomains: string[];
26
- }
27
-
28
- interface SandboxConfig {
29
- enabled: boolean;
30
- network: SandboxNetworkConfig;
31
- filesystem: SandboxFilesystemConfig;
32
- }
33
-
34
- interface SandboxConfigOverrides {
35
- enabled?: boolean;
36
- network?: Partial<SandboxNetworkConfig>;
37
- filesystem?: Partial<SandboxFilesystemConfig>;
38
- }
39
-
40
- const DEFAULT_CONFIG: SandboxConfig = {
41
- enabled: true,
42
- network: {
43
- allowNetwork: false,
44
- allowLocalBinding: false,
45
- allowAllUnixSockets: false,
46
- allowUnixSockets: [],
47
- allowedDomains: [],
48
- deniedDomains: [],
49
- },
50
- filesystem: {
51
- denyRead: ['/Users', '/home'],
52
- allowRead: ['.', '~/.gitconfig', '/dev/null'],
53
- allowWrite: ['.', '/dev/null'],
54
- denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
55
- },
56
- };
57
- const LANDSTRIP_PACKAGE_NAMES = new Set([
58
- '@jarkkojs/landstrip',
59
- '@jarkkojs/landstrip-darwin-arm64',
60
- '@jarkkojs/landstrip-darwin-x64',
61
- '@jarkkojs/landstrip-linux-x64',
62
- '@jarkkojs/landstrip-win32-x64',
63
- ]);
64
-
65
- function isRecord(value: unknown): value is Record<string, unknown> {
66
- return typeof value === 'object' && value !== null && !Array.isArray(value);
67
- }
68
-
69
- function stringArray(value: unknown): string[] | undefined {
70
- if (!Array.isArray(value)) return undefined;
71
- return value.every((item) => typeof item === 'string') ? [...value] : undefined;
72
- }
73
-
74
- function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
75
- if (!isRecord(value)) return undefined;
76
-
77
- const config: Partial<SandboxNetworkConfig> = {};
78
- if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
79
- if (typeof value.allowLocalBinding === 'boolean')
80
- config.allowLocalBinding = value.allowLocalBinding;
81
- if (typeof value.allowAllUnixSockets === 'boolean')
82
- config.allowAllUnixSockets = value.allowAllUnixSockets;
83
-
84
- const allowUnixSockets = stringArray(value.allowUnixSockets);
85
- if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
86
-
87
- const allowedDomains = stringArray(value.allowedDomains);
88
- if (allowedDomains) config.allowedDomains = allowedDomains;
89
-
90
- const deniedDomains = stringArray(value.deniedDomains);
91
- if (deniedDomains) config.deniedDomains = deniedDomains;
92
-
93
- return config;
94
- }
95
-
96
- function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
97
- if (!isRecord(value)) return undefined;
98
-
99
- const config: Partial<SandboxFilesystemConfig> = {};
100
- const denyRead = stringArray(value.denyRead);
101
- if (denyRead) config.denyRead = denyRead;
102
-
103
- const allowRead = stringArray(value.allowRead);
104
- if (allowRead) config.allowRead = allowRead;
105
-
106
- const allowWrite = stringArray(value.allowWrite);
107
- if (allowWrite) config.allowWrite = allowWrite;
108
-
109
- const denyWrite = stringArray(value.denyWrite);
110
- if (denyWrite) config.denyWrite = denyWrite;
111
-
112
- return config;
113
- }
114
-
115
- function normalizeConfig(value: unknown): SandboxConfigOverrides {
116
- if (!isRecord(value)) return {};
117
-
118
- const config: SandboxConfigOverrides = {};
119
- if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
120
-
121
- const network = normalizeNetworkConfig(value.network);
122
- if (network) config.network = network;
123
-
124
- const filesystem = normalizeFilesystemConfig(value.filesystem);
125
- if (filesystem) config.filesystem = filesystem;
126
-
127
- return config;
128
- }
129
-
130
- function normalizeOptions(options: unknown): SandboxConfigOverrides {
131
- if (!isRecord(options)) return {};
132
- return normalizeConfig(isRecord(options.config) ? options.config : options);
133
- }
134
-
135
- function mergeArray(base: string[], override?: string[]): string[] {
136
- if (!override) return base;
137
- return [...new Set([...base, ...override])];
138
- }
139
-
140
- function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
141
- const network = overrides.network;
142
- const filesystem = overrides.filesystem;
143
-
144
- return {
145
- enabled: overrides.enabled ?? base.enabled,
146
- network: {
147
- allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
148
- allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
149
- allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
150
- allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
151
- allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
152
- deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
153
- },
154
- filesystem: {
155
- denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
156
- allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
157
- allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
158
- denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
159
- },
160
- };
161
- }
162
-
163
- function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
164
- return {
165
- globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
166
- projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
167
- };
168
- }
169
-
170
- function readConfigFile(configPath: string): SandboxConfigOverrides | null {
171
- if (!existsSync(configPath)) return {};
172
-
173
- try {
174
- return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
175
- } catch {
176
- return null;
177
- }
178
- }
179
-
180
- function landstripBinaryPath(): string {
181
- const filePath = realpathSync.native(binaryPath());
182
- let probe = dirname(filePath);
183
-
184
- while (true) {
185
- const manifestPath = join(probe, 'package.json');
186
- if (existsSync(manifestPath)) {
187
- try {
188
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
189
- if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
190
- return filePath;
191
- }
192
- } catch {
193
- // malformed package.json — continue walking to parent
194
- }
195
- }
196
-
197
- const parent = dirname(probe);
198
- if (parent === probe) break;
199
- probe = parent;
200
- }
201
-
202
- throw new Error(
203
- `Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
204
- );
4
+ import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
5
+
6
+ import { existsSync } from 'node:fs';
7
+
8
+ import {
9
+ type SandboxConfigOverrides,
10
+ getConfigPaths,
11
+ landstripBinaryPath,
12
+ loadConfig,
13
+ normalizeOptions,
14
+ permissionLabel,
15
+ permissionResource,
16
+ updateForPermission,
17
+ writeConfigFile,
18
+ } from './shared.js';
19
+
20
+ // The shape shared by the `permission.asked` event payload and the entries
21
+ // returned from `api.state.session.permission()`. Both carry `permission`
22
+ // (the kind), `patterns`, and `tool.callID`; neither carries a `title`.
23
+ interface PendingPermission {
24
+ id: string;
25
+ sessionID: string;
26
+ permission: string;
27
+ patterns: string[];
28
+ metadata: Record<string, unknown>;
29
+ tool?: { callID: string };
205
30
  }
206
31
 
207
- function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
208
- const current = readConfigFile(configPath);
209
- if (current === null) {
210
- throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
211
- }
212
-
213
- const next = deepMerge(deepMerge(DEFAULT_CONFIG, current), update);
214
-
215
- mkdirSync(dirname(configPath), { recursive: true });
216
- writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
217
- }
218
-
219
- function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
220
- const { globalPath, projectPath } = getConfigPaths(baseDirectory);
221
- const globalConfig = readConfigFile(globalPath);
222
- const projectConfig = readConfigFile(projectPath);
223
- return deepMerge(
224
- deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig ?? {}), projectConfig ?? {}),
225
- optionOverrides,
226
- );
227
- }
32
+ type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
228
33
 
229
34
  function list(values: string[]): string {
230
35
  return values.join(', ') || '(none)';
@@ -263,85 +68,72 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
263
68
  ].join('\n');
264
69
  }
265
70
 
266
- type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
267
-
268
- function permissionType(permission: Record<string, unknown>, fallback = ''): string {
269
- if (typeof permission.permission === 'string') return permission.permission;
270
- if (typeof permission.action === 'string') return permission.action;
271
- if (typeof permission.type === 'string') return permission.type;
272
- return fallback;
71
+ function asRecord(permission: PendingPermission): Record<string, unknown> {
72
+ return permission as unknown as Record<string, unknown>;
273
73
  }
274
74
 
275
- function permissionPattern(permission: Record<string, unknown>): string | undefined {
276
- const patterns = permission.patterns;
277
- if (Array.isArray(patterns))
278
- return patterns.find((item): item is string => typeof item === 'string');
279
-
280
- const pattern = permission.pattern;
281
- if (typeof pattern === 'string') return pattern;
282
- if (Array.isArray(pattern))
283
- return pattern.find((item): item is string => typeof item === 'string');
284
-
285
- return undefined;
75
+ function permissionDetail(permission: PendingPermission): string {
76
+ const label = permissionLabel(asRecord(permission));
77
+ const resource = permissionResource(asRecord(permission));
78
+ return resource && !label.includes(resource) ? `${label}: ${resource}` : label;
286
79
  }
287
80
 
288
- function domainsFromCommand(command: string): string[] {
289
- const domains = new Set<string>();
290
- const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
291
- let match: RegExpExecArray | null;
292
-
293
- while ((match = urlRegex.exec(command)) !== null) domains.add(match[1]);
294
-
295
- return [...domains];
296
- }
297
-
298
- function updateForPermission(permission: Record<string, unknown>): SandboxConfigOverrides | null {
299
- const metadata = isRecord(permission.metadata) ? permission.metadata : {};
300
- const type = permissionType(permission);
301
- const pattern = permissionPattern(permission);
302
-
303
- if (type === 'bash') {
304
- const command = typeof metadata.command === 'string' ? metadata.command : pattern;
305
- const domains = typeof command === 'string' ? domainsFromCommand(command) : [];
306
- return domains.length > 0 ? { network: { allowedDomains: domains } } : null;
81
+ const tui: TuiPlugin = async (api, options, meta) => {
82
+ const optionOverrides = normalizeOptions(options);
83
+
84
+ // Permission requests can arrive twice (the live event and a reconnect replay
85
+ // of `api.state`), so `resolved` tracks ids we have already answered and
86
+ // `activeId` guards against stacking a second sandbox dialog on the first.
87
+ const resolved = new Set<string>();
88
+ const queue: PendingPermission[] = [];
89
+ let activeId: string | undefined;
90
+
91
+ function pump(): void {
92
+ if (activeId !== undefined) return;
93
+ let next = queue.shift();
94
+ while (next && resolved.has(next.id)) next = queue.shift();
95
+ if (!next) return;
96
+ showPermission(next);
307
97
  }
308
98
 
309
- if (type === 'read' || type === 'glob' || type === 'grep' || type === 'list') {
310
- const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
311
- return filePath ? { filesystem: { allowRead: [filePath] } } : null;
99
+ function enqueue(permission: PendingPermission): void {
100
+ if (!permission.id || resolved.has(permission.id)) return;
101
+ if (activeId === permission.id) return;
102
+ if (queue.some((item) => item.id === permission.id)) return;
103
+ queue.push(permission);
104
+ pump();
312
105
  }
313
106
 
314
- if (type === 'edit' || type === 'write' || type === 'apply_patch') {
315
- const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
316
- return filePath ? { filesystem: { allowWrite: [filePath] } } : null;
107
+ // Safety net for missed/late events and reconnects: fold whatever the host
108
+ // still considers pending for this session back into the queue.
109
+ function reconcile(sessionID: string): void {
110
+ for (const pending of api.state.session.permission(sessionID)) {
111
+ enqueue(pending as PendingPermission);
112
+ }
317
113
  }
318
114
 
319
- return null;
320
- }
321
-
322
- function permissionLabel(permission: Record<string, unknown>): string {
323
- const type = permissionType(permission, 'permission');
324
- const title = typeof permission.title === 'string' ? permission.title : type;
325
- const pattern = permissionPattern(permission);
326
- return pattern ? `${title}: ${pattern}` : title;
327
- }
328
-
329
- const tui: TuiPlugin = async (api, options) => {
330
- const handledPermissions = new Set<string>();
115
+ function finishActive(id: string): void {
116
+ resolved.add(id);
117
+ if (activeId === id) {
118
+ activeId = undefined;
119
+ api.ui.dialog.clear();
120
+ }
121
+ pump();
122
+ }
331
123
 
332
124
  async function replyPermission(
333
- permission: Record<string, unknown>,
125
+ permission: PendingPermission,
334
126
  choice: PermissionChoice,
335
127
  ): Promise<void> {
336
- const id = typeof permission.id === 'string' ? permission.id : undefined;
337
- if (!id || typeof permission.sessionID !== 'string') return;
128
+ const { id, sessionID } = permission;
129
+ if (!id || !sessionID) return;
338
130
 
339
131
  const directory = api.state.path.directory || process.cwd();
340
132
  const { globalPath, projectPath } = getConfigPaths(directory);
341
133
 
342
134
  try {
343
135
  if (choice === 'project' || choice === 'global') {
344
- const update = updateForPermission(permission);
136
+ const update = updateForPermission(asRecord(permission));
345
137
  if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
346
138
  }
347
139
 
@@ -362,54 +154,84 @@ const tui: TuiPlugin = async (api, options) => {
362
154
  variant: 'warning',
363
155
  });
364
156
  } finally {
365
- api.ui.dialog.clear();
157
+ finishActive(id);
366
158
  }
367
159
  }
368
160
 
369
- function showPermission(permission: Record<string, unknown>): void {
370
- const id = typeof permission.id === 'string' ? permission.id : undefined;
371
- if (!id || handledPermissions.has(id)) return;
372
- handledPermissions.add(id);
161
+ function showPermission(permission: PendingPermission): void {
162
+ activeId = permission.id;
163
+
164
+ void api.attention.notify({
165
+ title: 'Sandbox permission',
166
+ message: permissionDetail(permission),
167
+ sound: { name: 'permission' },
168
+ notification: true,
169
+ });
373
170
 
374
171
  api.ui.dialog.replace(
375
172
  () =>
376
173
  api.ui.DialogSelect<PermissionChoice>({
377
174
  title: 'Sandbox Permission',
378
- placeholder: permissionLabel(permission),
175
+ placeholder: permissionDetail(permission),
379
176
  options: [
380
- { title: 'Allow once', value: 'once', description: 'Approve only this request' },
177
+ {
178
+ title: 'Allow once',
179
+ value: 'once',
180
+ category: 'This request',
181
+ description: 'Approve only this request',
182
+ },
381
183
  {
382
184
  title: 'Allow for session',
383
185
  value: 'session',
186
+ category: 'This request',
384
187
  description: 'Use OpenCode session approval for matching requests',
385
188
  },
386
189
  {
387
190
  title: 'Allow for project',
388
191
  value: 'project',
192
+ category: 'Persist to sandbox.json',
389
193
  description: 'Persist to .opencode/sandbox.json and approve this session',
390
194
  },
391
195
  {
392
196
  title: 'Allow globally',
393
197
  value: 'global',
198
+ category: 'Persist to sandbox.json',
394
199
  description: 'Persist to ~/.config/opencode/sandbox.json and approve this session',
395
200
  },
396
- { title: 'Reject', value: 'reject', description: 'Deny this request' },
201
+ {
202
+ title: 'Reject',
203
+ value: 'reject',
204
+ category: 'Deny',
205
+ description: 'Deny this request',
206
+ },
397
207
  ],
398
208
  onSelect: (option) => {
399
209
  void replyPermission(permission, option.value);
400
210
  },
401
211
  }),
402
- () => api.ui.dialog.clear(),
212
+ () => {
213
+ // Dialog dismissed (esc) without a choice: drop our hold so the next
214
+ // pending permission can surface, but leave it unresolved upstream.
215
+ if (activeId === permission.id) activeId = undefined;
216
+ api.ui.dialog.clear();
217
+ pump();
218
+ },
403
219
  );
404
220
  }
405
221
 
406
- api.event.on('permission.asked', (event) => {
407
- showPermission(event.properties as Record<string, unknown>);
222
+ const unsubscribeAsked = api.event.on('permission.asked', (event) => {
223
+ const pending = event.properties as PendingPermission;
224
+ enqueue(pending);
225
+ reconcile(pending.sessionID);
226
+ });
227
+
228
+ const unsubscribeReplied = api.event.on('permission.replied', (event) => {
229
+ finishActive(event.properties.requestID);
408
230
  });
409
231
 
410
232
  const showSandbox = () => {
411
233
  const directory = api.state.path.directory || process.cwd();
412
- const message = sandboxSummary(directory, normalizeOptions(options));
234
+ const message = sandboxSummary(directory, optionOverrides);
413
235
 
414
236
  api.ui.dialog.replace(
415
237
  () =>
@@ -430,64 +252,85 @@ const tui: TuiPlugin = async (api, options) => {
430
252
  api.keymap.registerLayer({
431
253
  commands: [
432
254
  {
255
+ namespace: 'palette',
433
256
  name: 'sandbox',
434
257
  title: 'Sandbox',
435
- description: 'Show sandbox configuration',
258
+ desc: 'Show sandbox configuration',
436
259
  category: 'Sandbox',
437
260
  suggested: true,
438
- slash: { name: 'sandbox' },
261
+ slashName: 'sandbox',
439
262
  run: showSandbox,
440
263
  },
441
264
  {
265
+ namespace: 'palette',
442
266
  name: 'sandbox-disable',
443
267
  title: 'Disable sandbox',
444
- description: 'Disable sandbox for this session',
268
+ desc: 'Disable sandbox for this session',
445
269
  category: 'Sandbox',
446
270
  suggested: true,
447
- slash: { name: 'sandbox-disable' },
271
+ slashName: 'sandbox-disable',
448
272
  run: () => executeServerCommand('sandbox-disable'),
449
273
  },
450
274
  {
275
+ namespace: 'palette',
451
276
  name: 'sandbox-enable',
452
277
  title: 'Enable sandbox',
453
- description: 'Re-enable sandbox for this session',
278
+ desc: 'Re-enable sandbox for this session',
454
279
  category: 'Sandbox',
455
280
  suggested: true,
456
- slash: { name: 'sandbox-enable' },
281
+ slashName: 'sandbox-enable',
457
282
  run: () => executeServerCommand('sandbox-enable'),
458
283
  },
459
284
  ],
460
285
  });
461
286
 
462
- api.command?.register(() => [
463
- {
464
- title: 'Sandbox',
465
- value: 'sandbox',
466
- description: 'Show sandbox configuration',
467
- category: 'Sandbox',
468
- suggested: true,
469
- slash: { name: 'sandbox' },
470
- onSelect: showSandbox,
471
- },
472
- {
473
- title: 'Disable sandbox',
474
- value: 'sandbox-disable',
475
- description: 'Disable sandbox for this session',
476
- category: 'Sandbox',
477
- suggested: true,
478
- slash: { name: 'sandbox-disable' },
479
- onSelect: () => executeServerCommand('sandbox-disable'),
480
- },
481
- {
482
- title: 'Enable sandbox',
483
- value: 'sandbox-enable',
484
- description: 'Re-enable sandbox for this session',
485
- category: 'Sandbox',
486
- suggested: true,
487
- slash: { name: 'sandbox-enable' },
488
- onSelect: () => executeServerCommand('sandbox-enable'),
489
- },
490
- ]);
287
+ // Persistent status badge in the prompt area. It needs the host's Solid
288
+ // runtime, imported defensively so a host that resolves plugin imports
289
+ // differently still loads the plugin — the badge just stays absent there.
290
+ try {
291
+ const { jsx } = await import('@opentui/solid/jsx-runtime');
292
+ const statusBadge = (ctx: TuiSlotContext) => {
293
+ const directory = api.state.path.directory || process.cwd();
294
+ const config = loadConfig(directory, optionOverrides);
295
+ const theme = ctx.theme.current;
296
+
297
+ if (!config.enabled) return jsx('text', { fg: theme.textMuted, children: 'sandbox off' });
298
+
299
+ const open = config.network.allowNetwork;
300
+ return jsx('text', {
301
+ fg: open ? theme.warning : theme.success,
302
+ children: `sandbox · ${open ? 'net open' : 'net proxied'}`,
303
+ });
304
+ };
305
+
306
+ const statusSlot: TuiSlotPlugin = {
307
+ slots: {
308
+ home_prompt_right: (ctx) => statusBadge(ctx),
309
+ session_prompt_right: (ctx) => statusBadge(ctx),
310
+ },
311
+ };
312
+ api.slots.register(statusSlot);
313
+ } catch {
314
+ // Solid runtime unavailable on this host — skip the status badge.
315
+ }
316
+
317
+ // First-run onboarding: a single quiet pointer to the default-strict policy
318
+ // and the inspector command. `meta.state` flags a freshly installed plugin;
319
+ // the kv flag keeps it from repeating across reloads.
320
+ if (meta.state === 'first' && !api.kv.get<boolean>('onboarded', false)) {
321
+ api.kv.set('onboarded', true);
322
+ api.ui.toast({
323
+ title: 'Sandbox active',
324
+ message: 'Landlock policy is on (default strict). Run /sandbox to inspect it.',
325
+ variant: 'info',
326
+ duration: 8000,
327
+ });
328
+ }
329
+
330
+ api.lifecycle.onDispose(() => {
331
+ unsubscribeAsked();
332
+ unsubscribeReplied();
333
+ });
491
334
  };
492
335
 
493
336
  export { tui };