opencode-landstrip 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +28 -10
  2. package/index.ts +151 -218
  3. package/package.json +8 -7
  4. package/sandbox.json +7 -47
  5. package/shared.ts +318 -0
  6. package/tui.ts +265 -200
package/sandbox.json CHANGED
@@ -1,57 +1,17 @@
1
1
  {
2
2
  "enabled": true,
3
3
  "network": {
4
- "allowLocalBinding": true,
4
+ "allowNetwork": false,
5
+ "allowLocalBinding": false,
5
6
  "allowAllUnixSockets": false,
6
7
  "allowUnixSockets": [],
7
- "allowedDomains": [
8
- "github.com",
9
- "*.github.com",
10
- "api.github.com",
11
- "raw.githubusercontent.com",
12
- "objects.githubusercontent.com",
13
- "codeload.github.com",
14
- "registry.npmjs.org",
15
- "npmjs.org",
16
- "*.npmjs.org",
17
- "nodejs.org",
18
- "*.nodejs.org",
19
- "crates.io",
20
- "*.crates.io",
21
- "static.crates.io"
22
- ],
8
+ "allowedDomains": [],
23
9
  "deniedDomains": []
24
10
  },
25
11
  "filesystem": {
26
- "denyRead": ["/home"],
27
- "allowRead": [
28
- ".",
29
- "/tmp",
30
- "/var/tmp",
31
- "/dev/null",
32
- "~/.config/opencode",
33
- "~/.config/git",
34
- "~/.gitconfig",
35
- "~/.local",
36
- "~/.cargo",
37
- "~/.rustup",
38
- "~/.npm",
39
- "~/.cache",
40
- "~/.bun",
41
- "~/.node-gyp"
42
- ],
43
- "allowWrite": [
44
- ".",
45
- "/tmp",
46
- "/var/tmp",
47
- "/dev/null",
48
- "~/.cargo",
49
- "~/.rustup",
50
- "~/.npm",
51
- "~/.cache",
52
- "~/.bun",
53
- "~/.node-gyp"
54
- ],
55
- "denyWrite": [".env", ".env.*", "*.pem", "*.key"]
12
+ "denyRead": ["/Users", "/home"],
13
+ "allowRead": [".", "~/.gitconfig", "/dev/null"],
14
+ "allowWrite": [".", "/dev/null"],
15
+ "denyWrite": ["**/.env", "**/.env.*", "**/*.pem", "**/*.key"]
56
16
  }
57
17
  }
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
+ }