opencode-landstrip 0.1.0
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/LICENSE +21 -0
- package/README.md +59 -0
- package/index.ts +906 -0
- package/package.json +60 -0
- package/sandbox.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jarkko Sakkinen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# opencode-landstrip
|
|
2
|
+
|
|
3
|
+
Landlock-based sandboxing for [opencode](https://opencode.ai/) using
|
|
4
|
+
[`landstrip`](https://github.com/jarkkojs/landstrip).
|
|
5
|
+
|
|
6
|
+
## Prerequisites
|
|
7
|
+
|
|
8
|
+
Install `landstrip` and make sure it is on the `PATH` used to launch opencode:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
cargo install landstrip
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`landstrip` supports Linux, macOS, and Windows. On other platforms this plugin
|
|
15
|
+
loads but leaves sandboxing disabled.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Add the plugin to `opencode.json`:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"$schema": "https://opencode.ai/config.json",
|
|
24
|
+
"plugin": ["opencode-landstrip"]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configure
|
|
29
|
+
|
|
30
|
+
Create `.opencode/sandbox.json` in a project or
|
|
31
|
+
`~/.config/opencode/sandbox.json` globally. Project config takes precedence.
|
|
32
|
+
|
|
33
|
+
See [`sandbox.json`](./sandbox.json) for a starter config.
|
|
34
|
+
|
|
35
|
+
## Behavior
|
|
36
|
+
|
|
37
|
+
The plugin wraps opencode's AI `bash` tool in `landstrip`, routes proxy-aware
|
|
38
|
+
network traffic through an allowlist proxy, and blocks read/write tool access
|
|
39
|
+
outside configured filesystem allowlists.
|
|
40
|
+
|
|
41
|
+
opencode's current server plugin API does not expose Pi-style custom permission
|
|
42
|
+
dialogs or a way to rewrite manually typed shell-mode commands. Blocked access
|
|
43
|
+
fails with an error that points at the sandbox config files.
|
|
44
|
+
|
|
45
|
+
## Disable
|
|
46
|
+
|
|
47
|
+
Set `enabled` to `false` in `sandbox.json`, or pass plugin options:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"$schema": "https://opencode.ai/config.json",
|
|
52
|
+
"plugin": [["opencode-landstrip", { "enabled": false }]]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
`opencode-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
|
|
59
|
+
information.
|
package/index.ts
ADDED
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
|
+
|
|
4
|
+
import type { Hooks, Plugin, PluginInput, PluginOptions } from '@opencode-ai/plugin';
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { type AddressInfo, connect as connectNet, createServer, type Socket } from 'node:net';
|
|
16
|
+
import { homedir, tmpdir } from 'node:os';
|
|
17
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
18
|
+
import { URL } from 'node:url';
|
|
19
|
+
|
|
20
|
+
interface SandboxFilesystemConfig {
|
|
21
|
+
denyRead: string[];
|
|
22
|
+
allowRead: string[];
|
|
23
|
+
allowWrite: string[];
|
|
24
|
+
denyWrite: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SandboxNetworkConfig {
|
|
28
|
+
allowLocalBinding: boolean;
|
|
29
|
+
allowAllUnixSockets: boolean;
|
|
30
|
+
allowUnixSockets: string[];
|
|
31
|
+
allowedDomains: string[];
|
|
32
|
+
deniedDomains: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface LandstripConfig {
|
|
36
|
+
command: string;
|
|
37
|
+
debug: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SandboxConfig {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
network: SandboxNetworkConfig;
|
|
43
|
+
filesystem: SandboxFilesystemConfig;
|
|
44
|
+
landstrip: LandstripConfig;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface LandstripPolicy {
|
|
48
|
+
network: {
|
|
49
|
+
allowLocalBinding: boolean;
|
|
50
|
+
allowAllUnixSockets: boolean;
|
|
51
|
+
allowUnixSockets: string[];
|
|
52
|
+
httpProxyPort: number;
|
|
53
|
+
};
|
|
54
|
+
filesystem: SandboxFilesystemConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SandboxConfigOverrides {
|
|
58
|
+
enabled?: boolean;
|
|
59
|
+
network?: Partial<SandboxNetworkConfig>;
|
|
60
|
+
filesystem?: Partial<SandboxFilesystemConfig>;
|
|
61
|
+
landstrip?: Partial<LandstripConfig>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface BashSandboxState {
|
|
65
|
+
policyDir: string;
|
|
66
|
+
port: number;
|
|
67
|
+
stop: () => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
71
|
+
|
|
72
|
+
const LANDSTRIP_VERSION = [0, 8, 3] as const;
|
|
73
|
+
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
74
|
+
|
|
75
|
+
const DEFAULT_CONFIG: SandboxConfig = {
|
|
76
|
+
enabled: true,
|
|
77
|
+
network: {
|
|
78
|
+
allowLocalBinding: false,
|
|
79
|
+
allowAllUnixSockets: false,
|
|
80
|
+
allowUnixSockets: [],
|
|
81
|
+
allowedDomains: [
|
|
82
|
+
'npmjs.org',
|
|
83
|
+
'*.npmjs.org',
|
|
84
|
+
'registry.npmjs.org',
|
|
85
|
+
'registry.yarnpkg.com',
|
|
86
|
+
'pypi.org',
|
|
87
|
+
'*.pypi.org',
|
|
88
|
+
'github.com',
|
|
89
|
+
'*.github.com',
|
|
90
|
+
'api.github.com',
|
|
91
|
+
'raw.githubusercontent.com',
|
|
92
|
+
'crates.io',
|
|
93
|
+
'*.crates.io',
|
|
94
|
+
'static.crates.io',
|
|
95
|
+
],
|
|
96
|
+
deniedDomains: [],
|
|
97
|
+
},
|
|
98
|
+
filesystem: {
|
|
99
|
+
denyRead: ['/Users', '/home'],
|
|
100
|
+
allowRead: ['.', '~/.config/opencode', '~/.local', '~/.cargo'],
|
|
101
|
+
allowWrite: ['.', '/tmp'],
|
|
102
|
+
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
103
|
+
},
|
|
104
|
+
landstrip: {
|
|
105
|
+
command: 'landstrip',
|
|
106
|
+
debug: false,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
111
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
115
|
+
if (!Array.isArray(value)) return undefined;
|
|
116
|
+
return value.every((item) => typeof item === 'string') ? [...value] : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
|
|
120
|
+
if (!isRecord(value)) return undefined;
|
|
121
|
+
|
|
122
|
+
const config: Partial<SandboxNetworkConfig> = {};
|
|
123
|
+
if (typeof value.allowLocalBinding === 'boolean')
|
|
124
|
+
config.allowLocalBinding = value.allowLocalBinding;
|
|
125
|
+
if (typeof value.allowAllUnixSockets === 'boolean')
|
|
126
|
+
config.allowAllUnixSockets = value.allowAllUnixSockets;
|
|
127
|
+
|
|
128
|
+
const allowUnixSockets = stringArray(value.allowUnixSockets);
|
|
129
|
+
if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
|
|
130
|
+
|
|
131
|
+
const allowedDomains = stringArray(value.allowedDomains);
|
|
132
|
+
if (allowedDomains) config.allowedDomains = allowedDomains;
|
|
133
|
+
|
|
134
|
+
const deniedDomains = stringArray(value.deniedDomains);
|
|
135
|
+
if (deniedDomains) config.deniedDomains = deniedDomains;
|
|
136
|
+
|
|
137
|
+
return config;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
|
|
141
|
+
if (!isRecord(value)) return undefined;
|
|
142
|
+
|
|
143
|
+
const config: Partial<SandboxFilesystemConfig> = {};
|
|
144
|
+
const denyRead = stringArray(value.denyRead);
|
|
145
|
+
if (denyRead) config.denyRead = denyRead;
|
|
146
|
+
|
|
147
|
+
const allowRead = stringArray(value.allowRead);
|
|
148
|
+
if (allowRead) config.allowRead = allowRead;
|
|
149
|
+
|
|
150
|
+
const allowWrite = stringArray(value.allowWrite);
|
|
151
|
+
if (allowWrite) config.allowWrite = allowWrite;
|
|
152
|
+
|
|
153
|
+
const denyWrite = stringArray(value.denyWrite);
|
|
154
|
+
if (denyWrite) config.denyWrite = denyWrite;
|
|
155
|
+
|
|
156
|
+
return config;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeLandstripConfig(value: unknown): Partial<LandstripConfig> | undefined {
|
|
160
|
+
if (!isRecord(value)) return undefined;
|
|
161
|
+
|
|
162
|
+
const config: Partial<LandstripConfig> = {};
|
|
163
|
+
if (typeof value.command === 'string') config.command = value.command;
|
|
164
|
+
if (typeof value.debug === 'boolean') config.debug = value.debug;
|
|
165
|
+
return config;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
169
|
+
if (!isRecord(value)) return {};
|
|
170
|
+
|
|
171
|
+
const config: SandboxConfigOverrides = {};
|
|
172
|
+
if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
|
|
173
|
+
|
|
174
|
+
const network = normalizeNetworkConfig(value.network);
|
|
175
|
+
if (network) config.network = network;
|
|
176
|
+
|
|
177
|
+
const filesystem = normalizeFilesystemConfig(value.filesystem);
|
|
178
|
+
if (filesystem) config.filesystem = filesystem;
|
|
179
|
+
|
|
180
|
+
const landstrip = normalizeLandstripConfig(value.landstrip);
|
|
181
|
+
if (landstrip) config.landstrip = landstrip;
|
|
182
|
+
|
|
183
|
+
return config;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOverrides {
|
|
187
|
+
if (!isRecord(options)) return {};
|
|
188
|
+
return normalizeConfig(isRecord(options.config) ? options.config : options);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
192
|
+
return {
|
|
193
|
+
enabled: overrides.enabled ?? base.enabled,
|
|
194
|
+
network: {
|
|
195
|
+
...base.network,
|
|
196
|
+
...overrides.network,
|
|
197
|
+
},
|
|
198
|
+
filesystem: {
|
|
199
|
+
...base.filesystem,
|
|
200
|
+
...overrides.filesystem,
|
|
201
|
+
},
|
|
202
|
+
landstrip: {
|
|
203
|
+
...base.landstrip,
|
|
204
|
+
...overrides.landstrip,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
|
|
210
|
+
return {
|
|
211
|
+
globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
|
|
212
|
+
projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readConfigFile(configPath: string): SandboxConfigOverrides {
|
|
217
|
+
if (!existsSync(configPath)) return {};
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error(`Warning: Could not parse ${configPath}: ${error}`);
|
|
223
|
+
return {};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
|
|
228
|
+
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
229
|
+
return deepMerge(
|
|
230
|
+
deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
|
|
231
|
+
optionOverrides,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function expandPath(filePath: string, baseDirectory: string): string {
|
|
236
|
+
const expanded = filePath.replace(/^~(?=$|[/])/, homedir());
|
|
237
|
+
return resolve(isAbsolute(expanded) ? expanded : join(baseDirectory, expanded));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function configuredShellPath(config: unknown): string | undefined {
|
|
241
|
+
if (!isRecord(config)) return undefined;
|
|
242
|
+
return typeof config.shell === 'string' ? config.shell : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function canonicalizePath(filePath: string, baseDirectory: string): string {
|
|
246
|
+
const abs = expandPath(filePath, baseDirectory);
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
return realpathSync.native(abs);
|
|
250
|
+
} catch {
|
|
251
|
+
const tail: string[] = [];
|
|
252
|
+
let probe = abs;
|
|
253
|
+
|
|
254
|
+
while (!existsSync(probe)) {
|
|
255
|
+
const parent = dirname(probe);
|
|
256
|
+
if (parent === probe) return abs;
|
|
257
|
+
tail.unshift(basename(probe));
|
|
258
|
+
probe = parent;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
return resolve(realpathSync.native(probe), ...tail);
|
|
263
|
+
} catch {
|
|
264
|
+
return abs;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function matchesPattern(filePath: string, patterns: string[], baseDirectory: string): boolean {
|
|
270
|
+
const abs = canonicalizePath(filePath, baseDirectory);
|
|
271
|
+
|
|
272
|
+
return patterns.some((pattern) => {
|
|
273
|
+
const absPattern = pattern.includes('*')
|
|
274
|
+
? expandPath(pattern, baseDirectory)
|
|
275
|
+
: canonicalizePath(pattern, baseDirectory);
|
|
276
|
+
|
|
277
|
+
if (pattern.includes('*')) {
|
|
278
|
+
const escaped = absPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
279
|
+
return new RegExp(`^${escaped}$`).test(abs);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const sep = absPattern.endsWith('/') ? '' : '/';
|
|
283
|
+
return abs === absPattern || abs.startsWith(absPattern + sep);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function resolveFilesystemPatterns(patterns: string[], baseDirectory: string): string[] {
|
|
288
|
+
return patterns.map((pattern) =>
|
|
289
|
+
pattern.includes('*')
|
|
290
|
+
? expandPath(pattern, baseDirectory)
|
|
291
|
+
: canonicalizePath(pattern, baseDirectory),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function resolveFilesystemConfig(
|
|
296
|
+
config: SandboxFilesystemConfig,
|
|
297
|
+
baseDirectory: string,
|
|
298
|
+
): SandboxFilesystemConfig {
|
|
299
|
+
return {
|
|
300
|
+
denyRead: resolveFilesystemPatterns(config.denyRead, baseDirectory),
|
|
301
|
+
allowRead: resolveFilesystemPatterns(config.allowRead, baseDirectory),
|
|
302
|
+
allowWrite: resolveFilesystemPatterns(config.allowWrite, baseDirectory),
|
|
303
|
+
denyWrite: resolveFilesystemPatterns(config.denyWrite, baseDirectory),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function shouldPromptForRead(path: string, allowRead: string[], baseDirectory: string): boolean {
|
|
308
|
+
return allowRead.length === 0 || !matchesPattern(path, allowRead, baseDirectory);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory: string): boolean {
|
|
312
|
+
return allowWrite.length === 0 || !matchesPattern(path, allowWrite, baseDirectory);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function extractDomainsFromCommand(command: string): string[] {
|
|
316
|
+
const urlRegex = /https?:\/\/([^\s/:?#]+)(?::\d+)?(?:[/?#]|\s|$)/g;
|
|
317
|
+
const domains = new Set<string>();
|
|
318
|
+
let match: RegExpExecArray | null;
|
|
319
|
+
|
|
320
|
+
while ((match = urlRegex.exec(command)) !== null) {
|
|
321
|
+
domains.add(match[1]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return [...domains];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function domainMatchesPattern(domain: string, pattern: string): boolean {
|
|
328
|
+
const normalizedDomain = domain.toLowerCase();
|
|
329
|
+
const normalizedPattern = pattern.toLowerCase();
|
|
330
|
+
|
|
331
|
+
if (normalizedPattern === '*') return true;
|
|
332
|
+
if (normalizedPattern.startsWith('*.')) {
|
|
333
|
+
const base = normalizedPattern.slice(2);
|
|
334
|
+
return normalizedDomain === base || normalizedDomain.endsWith(`.${base}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return normalizedDomain === normalizedPattern;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function domainMatchesAny(domain: string, patterns: string[]): boolean {
|
|
341
|
+
return patterns.some((pattern) => domainMatchesPattern(domain, pattern));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function allowsAllDomains(allowedDomains: string[]): boolean {
|
|
345
|
+
return allowedDomains.includes('*');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function firstBlockedDomain(
|
|
349
|
+
command: string,
|
|
350
|
+
config: SandboxConfig,
|
|
351
|
+
): { domain: string; reason: 'allowedDomains' | 'deniedDomains' } | null {
|
|
352
|
+
for (const domain of extractDomainsFromCommand(command)) {
|
|
353
|
+
if (domainMatchesAny(domain, config.network.deniedDomains)) {
|
|
354
|
+
return { domain, reason: 'deniedDomains' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!domainMatchesAny(domain, config.network.allowedDomains)) {
|
|
358
|
+
return { domain, reason: 'allowedDomains' };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function landstripVersion(command: string): string | null {
|
|
366
|
+
const result = spawnSync(command, ['--version'], { encoding: 'utf-8' });
|
|
367
|
+
if (result.status !== 0) return null;
|
|
368
|
+
return result.stdout.trim();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function parseVersion(version: string): [number, number, number] | null {
|
|
372
|
+
const match = version.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
|
|
373
|
+
if (!match) return null;
|
|
374
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function hasMinimumVersion(version: string, minimum: readonly [number, number, number]): boolean {
|
|
378
|
+
const parsed = parseVersion(version);
|
|
379
|
+
if (!parsed) return false;
|
|
380
|
+
|
|
381
|
+
for (let i = 0; i < minimum.length; i++) {
|
|
382
|
+
if (parsed[i] > minimum[i]) return true;
|
|
383
|
+
if (parsed[i] < minimum[i]) return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
390
|
+
const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
391
|
+
if (bracketMatch) {
|
|
392
|
+
return {
|
|
393
|
+
host: bracketMatch[1],
|
|
394
|
+
port: bracketMatch[2] ? Number(bracketMatch[2]) : defaultPort,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const lastColon = target.lastIndexOf(':');
|
|
399
|
+
if (lastColon > -1 && target.indexOf(':') === lastColon) {
|
|
400
|
+
return {
|
|
401
|
+
host: target.slice(0, lastColon),
|
|
402
|
+
port: Number(target.slice(lastColon + 1)),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { host: target, port: defaultPort };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function denyProxyRequest(client: Socket, status = '403 Forbidden'): void {
|
|
410
|
+
client.write(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`);
|
|
411
|
+
client.end();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): void {
|
|
415
|
+
upstream.on('error', () => client.destroy());
|
|
416
|
+
client.on('error', () => upstream.destroy());
|
|
417
|
+
|
|
418
|
+
if (initialData?.length) upstream.write(initialData);
|
|
419
|
+
|
|
420
|
+
client.pipe(upstream);
|
|
421
|
+
upstream.pipe(client);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function buildLandstripPolicy(
|
|
425
|
+
config: SandboxConfig,
|
|
426
|
+
baseDirectory: string,
|
|
427
|
+
proxyPort: number,
|
|
428
|
+
): LandstripPolicy {
|
|
429
|
+
return {
|
|
430
|
+
network: {
|
|
431
|
+
allowLocalBinding: config.network.allowLocalBinding,
|
|
432
|
+
allowAllUnixSockets: config.network.allowAllUnixSockets,
|
|
433
|
+
allowUnixSockets: config.network.allowUnixSockets,
|
|
434
|
+
httpProxyPort: proxyPort,
|
|
435
|
+
},
|
|
436
|
+
filesystem: resolveFilesystemConfig(config.filesystem, baseDirectory),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function writePolicyFile(
|
|
441
|
+
config: SandboxConfig,
|
|
442
|
+
baseDirectory: string,
|
|
443
|
+
proxyPort: number,
|
|
444
|
+
): { dir: string; path: string } {
|
|
445
|
+
const dir = mkdtempSync(join(tmpdir(), 'opencode-landstrip-'));
|
|
446
|
+
const path = join(dir, 'policy.json');
|
|
447
|
+
writeFileSync(
|
|
448
|
+
path,
|
|
449
|
+
JSON.stringify(buildLandstripPolicy(config, baseDirectory, proxyPort), null, 2) + '\n',
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
return { dir, path };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () => Promise<void> }> {
|
|
456
|
+
const sockets = new Set<Socket>();
|
|
457
|
+
|
|
458
|
+
function domainAllowed(domain: string): boolean {
|
|
459
|
+
if (domainMatchesAny(domain, config.network.deniedDomains)) return false;
|
|
460
|
+
return domainMatchesAny(domain, config.network.allowedDomains);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function handleConnect(client: Socket, target: string, rest: Buffer): Promise<void> {
|
|
464
|
+
const endpoint = splitHostPort(target, 443);
|
|
465
|
+
if (!endpoint || !Number.isFinite(endpoint.port)) {
|
|
466
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!domainAllowed(endpoint.host)) {
|
|
471
|
+
denyProxyRequest(client);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const upstream = connectNet(endpoint.port, endpoint.host, () => {
|
|
476
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
477
|
+
pipeSockets(client, upstream, rest);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
|
|
482
|
+
const lines = headerText.split(/\r?\n/);
|
|
483
|
+
const [method, rawTarget, version] = lines[0].split(' ');
|
|
484
|
+
|
|
485
|
+
if (!method || !rawTarget || !version) {
|
|
486
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let url: URL;
|
|
491
|
+
try {
|
|
492
|
+
url = new URL(rawTarget);
|
|
493
|
+
} catch {
|
|
494
|
+
const host = lines
|
|
495
|
+
.find((line) => line.toLowerCase().startsWith('host:'))
|
|
496
|
+
?.slice(5)
|
|
497
|
+
.trim();
|
|
498
|
+
if (!host) {
|
|
499
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
url = new URL(`http://${host}${rawTarget}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!domainAllowed(url.hostname)) {
|
|
506
|
+
denyProxyRequest(client);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
|
|
511
|
+
const path = `${url.pathname}${url.search}` || '/';
|
|
512
|
+
lines[0] = `${method} ${path} ${version}`;
|
|
513
|
+
|
|
514
|
+
const rewrittenHeader = lines
|
|
515
|
+
.filter((line) => !line.toLowerCase().startsWith('proxy-connection:'))
|
|
516
|
+
.join('\r\n');
|
|
517
|
+
const upstream = connectNet(port, url.hostname, () => {
|
|
518
|
+
upstream.write(`${rewrittenHeader}\r\n\r\n`);
|
|
519
|
+
pipeSockets(client, upstream, rest);
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function handleClient(client: Socket): void {
|
|
524
|
+
sockets.add(client);
|
|
525
|
+
client.on('close', () => sockets.delete(client));
|
|
526
|
+
client.on('error', () => sockets.delete(client));
|
|
527
|
+
|
|
528
|
+
let buffered = Buffer.alloc(0);
|
|
529
|
+
|
|
530
|
+
client.on('data', (chunk: Buffer) => {
|
|
531
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
532
|
+
const headerEnd = buffered.indexOf('\r\n\r\n');
|
|
533
|
+
if (headerEnd === -1) {
|
|
534
|
+
if (buffered.length > 65536)
|
|
535
|
+
denyProxyRequest(client, '431 Request Header Fields Too Large');
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
client.pause();
|
|
540
|
+
client.removeAllListeners('data');
|
|
541
|
+
|
|
542
|
+
const header = buffered.subarray(0, headerEnd).toString('utf-8');
|
|
543
|
+
const rest = buffered.subarray(headerEnd + 4);
|
|
544
|
+
const firstLine = header.split(/\r?\n/, 1)[0];
|
|
545
|
+
const [method, target] = firstLine.split(' ');
|
|
546
|
+
|
|
547
|
+
const task =
|
|
548
|
+
method?.toUpperCase() === 'CONNECT'
|
|
549
|
+
? handleConnect(client, target, rest)
|
|
550
|
+
: handleHttp(client, header, rest);
|
|
551
|
+
task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const server = createServer(handleClient);
|
|
556
|
+
let stopped = false;
|
|
557
|
+
|
|
558
|
+
return new Promise((resolvePromise, reject) => {
|
|
559
|
+
server.on('error', reject);
|
|
560
|
+
server.listen(0, '127.0.0.1', () => {
|
|
561
|
+
server.removeListener('error', reject);
|
|
562
|
+
const address = server.address() as AddressInfo;
|
|
563
|
+
|
|
564
|
+
resolvePromise({
|
|
565
|
+
port: address.port,
|
|
566
|
+
stop: () =>
|
|
567
|
+
new Promise<void>((done) => {
|
|
568
|
+
if (stopped) {
|
|
569
|
+
done();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
stopped = true;
|
|
573
|
+
for (const socket of sockets) socket.destroy();
|
|
574
|
+
server.close(() => done());
|
|
575
|
+
}),
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function proxyEnv(port: number): Record<string, string> {
|
|
582
|
+
const url = `http://127.0.0.1:${port}`;
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
HTTP_PROXY: url,
|
|
586
|
+
HTTPS_PROXY: url,
|
|
587
|
+
ALL_PROXY: url,
|
|
588
|
+
http_proxy: url,
|
|
589
|
+
https_proxy: url,
|
|
590
|
+
all_proxy: url,
|
|
591
|
+
NO_PROXY: '',
|
|
592
|
+
no_proxy: '',
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function shellQuote(value: string): string {
|
|
597
|
+
if (value.length === 0) return "''";
|
|
598
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function shellArgs(shell: string, command: string): string[] {
|
|
602
|
+
const name = basename(shell).toLowerCase();
|
|
603
|
+
if (name.includes('fish')) return [shell, '-c', command];
|
|
604
|
+
return [shell, '-lc', command];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function buildWrappedCommand(
|
|
608
|
+
config: SandboxConfig,
|
|
609
|
+
policyPath: string,
|
|
610
|
+
shell: string,
|
|
611
|
+
command: string,
|
|
612
|
+
): string {
|
|
613
|
+
const args = [
|
|
614
|
+
config.landstrip.command,
|
|
615
|
+
...(config.landstrip.debug ? ['--debug'] : []),
|
|
616
|
+
'-p',
|
|
617
|
+
policyPath,
|
|
618
|
+
...shellArgs(shell, command),
|
|
619
|
+
];
|
|
620
|
+
|
|
621
|
+
return args.map(shellQuote).join(' ');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function getToolPath(args: Record<string, unknown>): string | undefined {
|
|
625
|
+
const filePath = args.filePath ?? args.path;
|
|
626
|
+
return typeof filePath === 'string' ? filePath : undefined;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function getSearchPath(args: Record<string, unknown>): string {
|
|
630
|
+
return typeof args.path === 'string' ? args.path : '.';
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function extractPatchPaths(patchText: string): string[] {
|
|
634
|
+
const paths: string[] = [];
|
|
635
|
+
|
|
636
|
+
for (const line of patchText.split(/\r?\n/)) {
|
|
637
|
+
const fileMatch = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/);
|
|
638
|
+
if (fileMatch) {
|
|
639
|
+
paths.push(fileMatch[1].trim());
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const moveMatch = line.match(/^\*\*\* Move to: (.+)$/);
|
|
644
|
+
if (moveMatch) paths.push(moveMatch[1].trim());
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return paths;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function errorWithConfigPaths(baseDirectory: string, message: string): Error {
|
|
651
|
+
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
652
|
+
return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function assertReadAllowed(path: string, config: SandboxConfig, baseDirectory: string): void {
|
|
656
|
+
const filePath = canonicalizePath(path, baseDirectory);
|
|
657
|
+
if (!shouldPromptForRead(filePath, config.filesystem.allowRead, baseDirectory)) return;
|
|
658
|
+
|
|
659
|
+
throw errorWithConfigPaths(
|
|
660
|
+
baseDirectory,
|
|
661
|
+
`Sandbox: read access denied for "${filePath}" (not in filesystem.allowRead).`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function assertWriteAllowed(path: string, config: SandboxConfig, baseDirectory: string): void {
|
|
666
|
+
const filePath = canonicalizePath(path, baseDirectory);
|
|
667
|
+
|
|
668
|
+
if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
|
|
669
|
+
throw errorWithConfigPaths(
|
|
670
|
+
baseDirectory,
|
|
671
|
+
`Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (!shouldPromptForWrite(filePath, config.filesystem.allowWrite, baseDirectory)) return;
|
|
676
|
+
|
|
677
|
+
throw errorWithConfigPaths(
|
|
678
|
+
baseDirectory,
|
|
679
|
+
`Sandbox: write access denied for "${filePath}" (not in filesystem.allowWrite).`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function assertApplyPatchAllowed(
|
|
684
|
+
args: Record<string, unknown>,
|
|
685
|
+
config: SandboxConfig,
|
|
686
|
+
baseDirectory: string,
|
|
687
|
+
): void {
|
|
688
|
+
if (typeof args.patchText !== 'string') return;
|
|
689
|
+
for (const path of extractPatchPaths(args.patchText))
|
|
690
|
+
assertWriteAllowed(path, config, baseDirectory);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export default (async ({ client, directory }: PluginInput, options?: PluginOptions) => {
|
|
694
|
+
const optionOverrides = normalizeOptions(options);
|
|
695
|
+
const activeBash = new Map<string, BashSandboxState>();
|
|
696
|
+
const notified = new Set<string>();
|
|
697
|
+
let enabledNotified = false;
|
|
698
|
+
let configuredShell: string | undefined;
|
|
699
|
+
let landstripCheck:
|
|
700
|
+
| { command: string; ok: true; version: string }
|
|
701
|
+
| { command: string; ok: false; reason: string }
|
|
702
|
+
| undefined;
|
|
703
|
+
|
|
704
|
+
async function notifyOnce(key: string, message: string, variant: ToastVariant): Promise<void> {
|
|
705
|
+
if (notified.has(key)) return;
|
|
706
|
+
notified.add(key);
|
|
707
|
+
|
|
708
|
+
await client.tui
|
|
709
|
+
.showToast({
|
|
710
|
+
body: { title: 'opencode-landstrip', message, variant },
|
|
711
|
+
query: { directory },
|
|
712
|
+
})
|
|
713
|
+
.catch(() => undefined);
|
|
714
|
+
|
|
715
|
+
await client.app
|
|
716
|
+
.log({
|
|
717
|
+
body: {
|
|
718
|
+
service: 'opencode-landstrip',
|
|
719
|
+
level: variant === 'error' ? 'error' : variant === 'warning' ? 'warn' : 'info',
|
|
720
|
+
message,
|
|
721
|
+
},
|
|
722
|
+
query: { directory },
|
|
723
|
+
})
|
|
724
|
+
.catch(() => undefined);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function checkLandstrip(config: SandboxConfig): typeof landstripCheck {
|
|
728
|
+
if (landstripCheck?.command === config.landstrip.command) return landstripCheck;
|
|
729
|
+
|
|
730
|
+
if (!SUPPORTED_PLATFORMS.has(process.platform)) {
|
|
731
|
+
landstripCheck = {
|
|
732
|
+
command: config.landstrip.command,
|
|
733
|
+
ok: false,
|
|
734
|
+
reason: `landstrip sandboxing is not supported on ${process.platform}`,
|
|
735
|
+
};
|
|
736
|
+
return landstripCheck;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const version = landstripVersion(config.landstrip.command);
|
|
740
|
+
if (!version) {
|
|
741
|
+
landstripCheck = {
|
|
742
|
+
command: config.landstrip.command,
|
|
743
|
+
ok: false,
|
|
744
|
+
reason: `landstrip was not found. Install it with: cargo install landstrip`,
|
|
745
|
+
};
|
|
746
|
+
return landstripCheck;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
750
|
+
landstripCheck = {
|
|
751
|
+
command: config.landstrip.command,
|
|
752
|
+
ok: false,
|
|
753
|
+
reason: `landstrip 0.8.3 or newer is required; found: ${version}`,
|
|
754
|
+
};
|
|
755
|
+
return landstripCheck;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
landstripCheck = { command: config.landstrip.command, ok: true, version };
|
|
759
|
+
return landstripCheck;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function activeConfig(): Promise<SandboxConfig | null> {
|
|
763
|
+
const config = loadConfig(directory, optionOverrides);
|
|
764
|
+
if (!config.enabled) return null;
|
|
765
|
+
|
|
766
|
+
const check = checkLandstrip(config);
|
|
767
|
+
if (!check?.ok) {
|
|
768
|
+
await notifyOnce(
|
|
769
|
+
`disabled:${check?.reason ?? 'unknown'}`,
|
|
770
|
+
check?.reason ?? 'Sandbox disabled',
|
|
771
|
+
'error',
|
|
772
|
+
);
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (!enabledNotified) {
|
|
777
|
+
enabledNotified = true;
|
|
778
|
+
const networkLabel = allowsAllDomains(config.network.allowedDomains)
|
|
779
|
+
? 'all domains'
|
|
780
|
+
: `${config.network.allowedDomains.length} domains`;
|
|
781
|
+
await notifyOnce(
|
|
782
|
+
'enabled',
|
|
783
|
+
`Sandbox enabled: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
|
|
784
|
+
'info',
|
|
785
|
+
);
|
|
786
|
+
if (allowsAllDomains(config.network.allowedDomains)) {
|
|
787
|
+
await notifyOnce(
|
|
788
|
+
'network-all',
|
|
789
|
+
'Network sandbox allows all domains because network.allowedDomains contains "*".',
|
|
790
|
+
'warning',
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return config;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function cleanupBash(callID: string): Promise<void> {
|
|
799
|
+
const state = activeBash.get(callID);
|
|
800
|
+
if (!state) return;
|
|
801
|
+
|
|
802
|
+
activeBash.delete(callID);
|
|
803
|
+
await state.stop().catch(() => undefined);
|
|
804
|
+
rmSync(state.policyDir, { recursive: true, force: true });
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function prepareBash(
|
|
808
|
+
callID: string,
|
|
809
|
+
args: Record<string, unknown>,
|
|
810
|
+
config: SandboxConfig,
|
|
811
|
+
): Promise<void> {
|
|
812
|
+
if (typeof args.command !== 'string') return;
|
|
813
|
+
await cleanupBash(callID);
|
|
814
|
+
|
|
815
|
+
const blockedDomain = firstBlockedDomain(args.command, config);
|
|
816
|
+
if (blockedDomain) {
|
|
817
|
+
const reason =
|
|
818
|
+
blockedDomain.reason === 'deniedDomains'
|
|
819
|
+
? 'is blocked by network.deniedDomains'
|
|
820
|
+
: 'is not in network.allowedDomains';
|
|
821
|
+
throw errorWithConfigPaths(
|
|
822
|
+
directory,
|
|
823
|
+
`Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const proxy = await startProxy(config);
|
|
828
|
+
let policy: { dir: string; path: string };
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
policy = writePolicyFile(config, directory, proxy.port);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
await proxy.stop().catch(() => undefined);
|
|
834
|
+
throw error;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
activeBash.set(callID, {
|
|
838
|
+
policyDir: policy.dir,
|
|
839
|
+
port: proxy.port,
|
|
840
|
+
stop: proxy.stop,
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
args.command = buildWrappedCommand(
|
|
844
|
+
config,
|
|
845
|
+
policy.path,
|
|
846
|
+
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
847
|
+
args.command,
|
|
848
|
+
);
|
|
849
|
+
if (typeof args.description === 'string') args.description = `${args.description} (landstrip)`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const hooks: Hooks = {
|
|
853
|
+
config: async (config) => {
|
|
854
|
+
configuredShell = configuredShellPath(config);
|
|
855
|
+
},
|
|
856
|
+
|
|
857
|
+
'tool.execute.before': async (input, output) => {
|
|
858
|
+
if (!isRecord(output.args)) return;
|
|
859
|
+
|
|
860
|
+
const config = await activeConfig();
|
|
861
|
+
if (!config) return;
|
|
862
|
+
|
|
863
|
+
if (input.tool === 'bash') {
|
|
864
|
+
await prepareBash(input.callID, output.args, config);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (input.tool === 'read') {
|
|
869
|
+
const path = getToolPath(output.args);
|
|
870
|
+
if (path) assertReadAllowed(path, config, directory);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (input.tool === 'glob' || input.tool === 'grep' || input.tool === 'list') {
|
|
875
|
+
assertReadAllowed(getSearchPath(output.args), config, directory);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (input.tool === 'write' || input.tool === 'edit') {
|
|
880
|
+
const path = getToolPath(output.args);
|
|
881
|
+
if (path) assertWriteAllowed(path, config, directory);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (input.tool === 'apply_patch') assertApplyPatchAllowed(output.args, config, directory);
|
|
886
|
+
},
|
|
887
|
+
|
|
888
|
+
'shell.env': async (input, output) => {
|
|
889
|
+
if (!input.callID) return;
|
|
890
|
+
const state = activeBash.get(input.callID);
|
|
891
|
+
if (!state) return;
|
|
892
|
+
|
|
893
|
+
Object.assign(output.env, proxyEnv(state.port));
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
'tool.execute.after': async (input) => {
|
|
897
|
+
if (input.tool === 'bash') await cleanupBash(input.callID);
|
|
898
|
+
},
|
|
899
|
+
|
|
900
|
+
dispose: async () => {
|
|
901
|
+
await Promise.all([...activeBash.keys()].map((callID) => cleanupBash(callID)));
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
return hooks;
|
|
906
|
+
}) satisfies Plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-landstrip",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"landlock",
|
|
7
|
+
"landstrip",
|
|
8
|
+
"opencode-plugin",
|
|
9
|
+
"sandbox"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/jarkkojs/opencode-landstrip.git"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.ts",
|
|
18
|
+
"README.md",
|
|
19
|
+
"sandbox.json"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./index.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": "./index.ts",
|
|
26
|
+
"types": "./index.ts"
|
|
27
|
+
},
|
|
28
|
+
"./server": {
|
|
29
|
+
"import": "./index.ts",
|
|
30
|
+
"types": "./index.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"fmt": "oxfmt index.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md",
|
|
35
|
+
"lint": "oxlint index.ts",
|
|
36
|
+
"check": "tsc --noEmit",
|
|
37
|
+
"all": "npm run fmt && npm run lint && npm run check",
|
|
38
|
+
"ci:fmt": "oxfmt --check index.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md",
|
|
39
|
+
"ci:lint": "npm run lint",
|
|
40
|
+
"ci:check": "npm run check"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@opencode-ai/plugin": "^1.16.2",
|
|
44
|
+
"@types/node": "^24.0.0",
|
|
45
|
+
"oxfmt": "^0.53.0",
|
|
46
|
+
"oxlint": "^1.68.0",
|
|
47
|
+
"typescript": "^5.8.2"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@opencode-ai/plugin": "^1.16.2"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@opencode-ai/plugin": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"opencode": ">=1.16.2"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/sandbox.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"network": {
|
|
4
|
+
"allowLocalBinding": true,
|
|
5
|
+
"allowAllUnixSockets": false,
|
|
6
|
+
"allowUnixSockets": [],
|
|
7
|
+
"allowedDomains": [
|
|
8
|
+
"github.com",
|
|
9
|
+
"*.github.com",
|
|
10
|
+
"raw.githubusercontent.com",
|
|
11
|
+
"registry.npmjs.org",
|
|
12
|
+
"*.npmjs.org",
|
|
13
|
+
"crates.io",
|
|
14
|
+
"*.crates.io",
|
|
15
|
+
"static.crates.io"
|
|
16
|
+
],
|
|
17
|
+
"deniedDomains": []
|
|
18
|
+
},
|
|
19
|
+
"filesystem": {
|
|
20
|
+
"denyRead": ["/home"],
|
|
21
|
+
"allowRead": [".", "~/.config/opencode", "~/.local", "~/.cargo"],
|
|
22
|
+
"allowWrite": [".", "/tmp", "~/.cargo", "~/.rustup"],
|
|
23
|
+
"denyWrite": [".env", ".env.*", "*.pem", "*.key"]
|
|
24
|
+
}
|
|
25
|
+
}
|