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.
- package/README.md +11 -5
- package/index.ts +11 -225
- package/package.json +7 -6
- package/shared.ts +318 -0
- 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
|
|
54
|
-
|
|
55
|
-
|
|
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`.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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:
|
|
125
|
+
permission: PendingPermission,
|
|
334
126
|
choice: PermissionChoice,
|
|
335
127
|
): Promise<void> {
|
|
336
|
-
const
|
|
337
|
-
if (!id ||
|
|
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
|
-
|
|
157
|
+
finishActive(id);
|
|
366
158
|
}
|
|
367
159
|
}
|
|
368
160
|
|
|
369
|
-
function showPermission(permission:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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:
|
|
175
|
+
placeholder: permissionDetail(permission),
|
|
379
176
|
options: [
|
|
380
|
-
{
|
|
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
|
-
{
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
258
|
+
desc: 'Show sandbox configuration',
|
|
436
259
|
category: 'Sandbox',
|
|
437
260
|
suggested: true,
|
|
438
|
-
|
|
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
|
-
|
|
268
|
+
desc: 'Disable sandbox for this session',
|
|
445
269
|
category: 'Sandbox',
|
|
446
270
|
suggested: true,
|
|
447
|
-
|
|
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
|
-
|
|
278
|
+
desc: 'Re-enable sandbox for this session',
|
|
454
279
|
category: 'Sandbox',
|
|
455
280
|
suggested: true,
|
|
456
|
-
|
|
281
|
+
slashName: 'sandbox-enable',
|
|
457
282
|
run: () => executeServerCommand('sandbox-enable'),
|
|
458
283
|
},
|
|
459
284
|
],
|
|
460
285
|
});
|
|
461
286
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
{
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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 };
|