opencode-landstrip 0.3.11 → 0.3.12
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 +19 -7
- package/index.ts +202 -55
- package/package.json +5 -5
- package/sandbox.json +7 -47
- package/tui.ts +265 -43
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ manually:
|
|
|
20
20
|
```json
|
|
21
21
|
{
|
|
22
22
|
"$schema": "https://opencode.ai/config.json",
|
|
23
|
-
"plugin": ["opencode-landstrip"]
|
|
23
|
+
"plugin": ["opencode-landstrip/tui"]
|
|
24
24
|
}
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -34,7 +34,8 @@ On unsupported platforms the plugin loads but leaves sandboxing disabled.
|
|
|
34
34
|
## Configure
|
|
35
35
|
|
|
36
36
|
Create `.opencode/sandbox.json` in a project or
|
|
37
|
-
`~/.config/opencode/sandbox.json` globally. Project config takes precedence
|
|
37
|
+
`~/.config/opencode/sandbox.json` globally. Project config takes precedence and
|
|
38
|
+
array fields are merged with global/default values.
|
|
38
39
|
|
|
39
40
|
See [`sandbox.json`](./sandbox.json) for a starter config.
|
|
40
41
|
|
|
@@ -42,14 +43,22 @@ See [`sandbox.json`](./sandbox.json) for a starter config.
|
|
|
42
43
|
|
|
43
44
|
The plugin wraps opencode's AI `bash` tool in `landstrip`, routes proxy-aware
|
|
44
45
|
network traffic through an allowlist proxy, and blocks read/write tool access
|
|
45
|
-
outside configured filesystem allowlists.
|
|
46
|
+
outside configured filesystem allowlists. The default policy is strict: network
|
|
47
|
+
access is off unless domains are allowed, reads are limited to the project,
|
|
48
|
+
`~/.gitconfig`, and `/dev/null`, and writes are limited to the project and
|
|
49
|
+
`/dev/null`.
|
|
46
50
|
|
|
47
51
|
Run `/sandbox` in the TUI to inspect the active sandbox configuration.
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
When OpenCode asks for a sandboxed permission, the TUI plugin adds choices to
|
|
54
|
+
allow once, allow for the session, persist for the project, persist globally, or
|
|
55
|
+
reject. Project approvals are written to `.opencode/sandbox.json`; global
|
|
56
|
+
approvals are written to `~/.config/opencode/sandbox.json`.
|
|
57
|
+
|
|
58
|
+
OpenCode's current plugin API allows wrapping AI `bash` tool calls, but does not
|
|
59
|
+
allow a plugin to replace manually typed shell-mode commands with a landstrip
|
|
60
|
+
wrapper. Those commands can still receive the proxy environment from OpenCode,
|
|
61
|
+
but they are not process-sandboxed by this plugin.
|
|
53
62
|
|
|
54
63
|
## Disable
|
|
55
64
|
|
|
@@ -66,3 +75,6 @@ Set `enabled` to `false` in `sandbox.json`, or pass plugin options:
|
|
|
66
75
|
|
|
67
76
|
`opencode-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
|
|
68
77
|
information.
|
|
78
|
+
|
|
79
|
+
The bundled `@jarkkojs/landstrip` package is licensed separately as
|
|
80
|
+
`Apache-2.0 AND LGPL-2.1-or-later`.
|
package/index.ts
CHANGED
|
@@ -53,11 +53,12 @@ interface LandstripPolicy {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
interface LandstripErrorResponse {
|
|
56
|
-
reason: 'Other' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
|
|
56
|
+
reason: 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
|
|
57
57
|
file?: string;
|
|
58
|
+
operation?: 'read' | 'write';
|
|
58
59
|
program?: string;
|
|
59
60
|
type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
|
|
60
|
-
source
|
|
61
|
+
source?: string;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
interface SandboxConfigOverrides {
|
|
@@ -85,8 +86,48 @@ interface SandboxPermissionDecision {
|
|
|
85
86
|
|
|
86
87
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
87
88
|
|
|
88
|
-
const LANDSTRIP_VERSION = [0, 11,
|
|
89
|
+
const LANDSTRIP_VERSION = [0, 11, 9] as const;
|
|
90
|
+
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
91
|
+
const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorResponse['reason']>([
|
|
92
|
+
'Other',
|
|
93
|
+
'AccessDenied',
|
|
94
|
+
'LaunchFailed',
|
|
95
|
+
'SetupFailed',
|
|
96
|
+
'Usage',
|
|
97
|
+
]);
|
|
98
|
+
const LANDSTRIP_OPERATIONS = new Set<NonNullable<LandstripErrorResponse['operation']>>([
|
|
99
|
+
'read',
|
|
100
|
+
'write',
|
|
101
|
+
]);
|
|
102
|
+
const LANDSTRIP_ERROR_TYPES = new Set<NonNullable<LandstripErrorResponse['type']>>([
|
|
103
|
+
'filesystem',
|
|
104
|
+
'network',
|
|
105
|
+
'platform',
|
|
106
|
+
'launch',
|
|
107
|
+
'encoding',
|
|
108
|
+
]);
|
|
89
109
|
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
|
+
|
|
118
|
+
function isLandstripErrorReason(value: string): value is LandstripErrorResponse['reason'] {
|
|
119
|
+
return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorResponse['reason']);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isLandstripOperation(
|
|
123
|
+
value: string,
|
|
124
|
+
): value is NonNullable<LandstripErrorResponse['operation']> {
|
|
125
|
+
return LANDSTRIP_OPERATIONS.has(value as NonNullable<LandstripErrorResponse['operation']>);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isLandstripErrorType(value: string): value is NonNullable<LandstripErrorResponse['type']> {
|
|
129
|
+
return LANDSTRIP_ERROR_TYPES.has(value as NonNullable<LandstripErrorResponse['type']>);
|
|
130
|
+
}
|
|
90
131
|
|
|
91
132
|
const DEFAULT_CONFIG: SandboxConfig = {
|
|
92
133
|
enabled: true,
|
|
@@ -95,36 +136,14 @@ const DEFAULT_CONFIG: SandboxConfig = {
|
|
|
95
136
|
allowLocalBinding: false,
|
|
96
137
|
allowAllUnixSockets: false,
|
|
97
138
|
allowUnixSockets: [],
|
|
98
|
-
allowedDomains: [
|
|
99
|
-
'npmjs.org',
|
|
100
|
-
'*.npmjs.org',
|
|
101
|
-
'registry.npmjs.org',
|
|
102
|
-
'registry.yarnpkg.com',
|
|
103
|
-
'pypi.org',
|
|
104
|
-
'*.pypi.org',
|
|
105
|
-
'github.com',
|
|
106
|
-
'*.github.com',
|
|
107
|
-
'api.github.com',
|
|
108
|
-
'raw.githubusercontent.com',
|
|
109
|
-
'crates.io',
|
|
110
|
-
'*.crates.io',
|
|
111
|
-
'static.crates.io',
|
|
112
|
-
],
|
|
139
|
+
allowedDomains: [],
|
|
113
140
|
deniedDomains: [],
|
|
114
141
|
},
|
|
115
142
|
filesystem: {
|
|
116
143
|
denyRead: ['/Users', '/home'],
|
|
117
|
-
allowRead: [
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
'~/.config/opencode',
|
|
121
|
-
'~/.config/git',
|
|
122
|
-
'~/.gitconfig',
|
|
123
|
-
'~/.local',
|
|
124
|
-
'~/.cargo',
|
|
125
|
-
],
|
|
126
|
-
allowWrite: ['.', '/tmp', '/dev/null'],
|
|
127
|
-
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
144
|
+
allowRead: ['.', '~/.gitconfig', '/dev/null'],
|
|
145
|
+
allowWrite: ['.', '/dev/null'],
|
|
146
|
+
denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
|
|
128
147
|
},
|
|
129
148
|
};
|
|
130
149
|
|
|
@@ -198,16 +217,30 @@ function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOver
|
|
|
198
217
|
return normalizeConfig(isRecord(options.config) ? options.config : options);
|
|
199
218
|
}
|
|
200
219
|
|
|
220
|
+
function mergeArray(base: string[], override?: string[]): string[] {
|
|
221
|
+
if (!override) return base;
|
|
222
|
+
return [...new Set([...base, ...override])];
|
|
223
|
+
}
|
|
224
|
+
|
|
201
225
|
function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
226
|
+
const network = overrides.network;
|
|
227
|
+
const filesystem = overrides.filesystem;
|
|
228
|
+
|
|
202
229
|
return {
|
|
203
230
|
enabled: overrides.enabled ?? base.enabled,
|
|
204
231
|
network: {
|
|
205
|
-
|
|
206
|
-
|
|
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),
|
|
207
238
|
},
|
|
208
239
|
filesystem: {
|
|
209
|
-
|
|
210
|
-
|
|
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),
|
|
211
244
|
},
|
|
212
245
|
};
|
|
213
246
|
}
|
|
@@ -248,6 +281,33 @@ function configuredShellPath(config: unknown): string | undefined {
|
|
|
248
281
|
return typeof config.shell === 'string' ? config.shell : undefined;
|
|
249
282
|
}
|
|
250
283
|
|
|
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
|
+
|
|
251
311
|
function canonicalizePath(filePath: string, baseDirectory: string): string {
|
|
252
312
|
const abs = expandPath(filePath, baseDirectory);
|
|
253
313
|
|
|
@@ -527,7 +587,7 @@ function evaluateDomainPermission(
|
|
|
527
587
|
}
|
|
528
588
|
|
|
529
589
|
function landstripVersion(): string | null {
|
|
530
|
-
const result = spawnSync(
|
|
590
|
+
const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
|
|
531
591
|
if (result.status !== 0) return null;
|
|
532
592
|
return result.stdout.trim();
|
|
533
593
|
}
|
|
@@ -564,25 +624,19 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
|
|
|
564
624
|
if (key.length > 0 && value.length > 0) fields[key] = value;
|
|
565
625
|
}
|
|
566
626
|
|
|
567
|
-
if (
|
|
568
|
-
fields.reason &&
|
|
569
|
-
['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
|
|
570
|
-
fields.source
|
|
571
|
-
) {
|
|
627
|
+
if (fields.reason && isLandstripErrorReason(fields.reason)) {
|
|
572
628
|
const error: LandstripErrorResponse = {
|
|
573
|
-
reason: fields.reason
|
|
574
|
-
source: fields.source,
|
|
629
|
+
reason: fields.reason,
|
|
575
630
|
};
|
|
576
631
|
|
|
577
632
|
if (fields.file) error.file = fields.file;
|
|
633
|
+
if (fields.operation && isLandstripOperation(fields.operation)) {
|
|
634
|
+
error.operation = fields.operation;
|
|
635
|
+
}
|
|
578
636
|
if (fields.program) error.program = fields.program;
|
|
637
|
+
if (fields.source) error.source = fields.source;
|
|
579
638
|
|
|
580
|
-
if (
|
|
581
|
-
fields.type &&
|
|
582
|
-
['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
|
|
583
|
-
) {
|
|
584
|
-
error.type = fields.type as LandstripErrorResponse['type'];
|
|
585
|
-
}
|
|
639
|
+
if (fields.type && isLandstripErrorType(fields.type)) error.type = fields.type;
|
|
586
640
|
|
|
587
641
|
errors.push(error);
|
|
588
642
|
}
|
|
@@ -599,13 +653,16 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
|
|
|
599
653
|
if (err.file) {
|
|
600
654
|
parts.push(` (${err.file})`);
|
|
601
655
|
}
|
|
656
|
+
if (err.operation) {
|
|
657
|
+
parts.push(` ${err.operation}`);
|
|
658
|
+
}
|
|
602
659
|
if (err.program) {
|
|
603
660
|
parts.push(` ${err.program}`);
|
|
604
661
|
}
|
|
605
662
|
if (err.type) {
|
|
606
663
|
parts.push(`:${err.type}`);
|
|
607
664
|
}
|
|
608
|
-
parts.push(`: ${err.source}`);
|
|
665
|
+
if (err.source) parts.push(`: ${err.source}`);
|
|
609
666
|
|
|
610
667
|
return parts.join('');
|
|
611
668
|
})
|
|
@@ -838,12 +895,12 @@ function shellArgs(shell: string, command: string): string[] {
|
|
|
838
895
|
function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
|
|
839
896
|
const args = ['-p', policyPath, ...shellArgs(shell, command)];
|
|
840
897
|
|
|
841
|
-
return [
|
|
898
|
+
return [landstripBinaryPath(), ...args].map(shellQuote).join(' ');
|
|
842
899
|
}
|
|
843
900
|
|
|
844
901
|
function isGeneratedWrappedCommand(command: string): boolean {
|
|
845
902
|
return (
|
|
846
|
-
command.startsWith(`${shellQuote(
|
|
903
|
+
command.startsWith(`${shellQuote(landstripBinaryPath())} `) &&
|
|
847
904
|
command.includes(` ${shellQuote('-p')} `) &&
|
|
848
905
|
command.includes('opencode-landstrip-')
|
|
849
906
|
);
|
|
@@ -964,6 +1021,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
964
1021
|
|
|
965
1022
|
function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
|
|
966
1023
|
if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
|
|
1024
|
+
client.tui
|
|
1025
|
+
?.showToast?.({
|
|
1026
|
+
body: {
|
|
1027
|
+
title: 'Sandbox blocked',
|
|
1028
|
+
message: decision.message.slice(0, 120),
|
|
1029
|
+
variant: 'error',
|
|
1030
|
+
},
|
|
1031
|
+
})
|
|
1032
|
+
?.catch?.(() => undefined);
|
|
967
1033
|
throw errorWithConfigPaths(directory, decision.message);
|
|
968
1034
|
}
|
|
969
1035
|
|
|
@@ -995,7 +1061,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
995
1061
|
'# Sandbox Configuration',
|
|
996
1062
|
'',
|
|
997
1063
|
`Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
|
|
998
|
-
`landstrip: ${
|
|
1064
|
+
`landstrip package binary: ${landstripBinaryPath()}`,
|
|
999
1065
|
'',
|
|
1000
1066
|
'Config files:',
|
|
1001
1067
|
`- project: ${projectPath}`,
|
|
@@ -1082,7 +1148,17 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1082
1148
|
return landstripCheck;
|
|
1083
1149
|
}
|
|
1084
1150
|
|
|
1085
|
-
|
|
1151
|
+
let version: string | null;
|
|
1152
|
+
try {
|
|
1153
|
+
version = landstripVersion();
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
landstripCheck = {
|
|
1156
|
+
ok: false,
|
|
1157
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1158
|
+
};
|
|
1159
|
+
return landstripCheck;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1086
1162
|
if (!version) {
|
|
1087
1163
|
landstripCheck = {
|
|
1088
1164
|
ok: false,
|
|
@@ -1094,7 +1170,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1094
1170
|
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
1095
1171
|
landstripCheck = {
|
|
1096
1172
|
ok: false,
|
|
1097
|
-
reason: `landstrip
|
|
1173
|
+
reason: `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
|
|
1098
1174
|
};
|
|
1099
1175
|
return landstripCheck;
|
|
1100
1176
|
}
|
|
@@ -1107,7 +1183,14 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1107
1183
|
if (sandboxDisabled) return null;
|
|
1108
1184
|
|
|
1109
1185
|
const config = loadConfig(directory, optionOverrides);
|
|
1110
|
-
if (!config.enabled)
|
|
1186
|
+
if (!config.enabled) {
|
|
1187
|
+
await notifyOnce(
|
|
1188
|
+
`not-configured:${directory}`,
|
|
1189
|
+
'Sandbox is not configured — no sandbox.json5 found',
|
|
1190
|
+
'info',
|
|
1191
|
+
);
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1111
1194
|
|
|
1112
1195
|
const check = checkLandstrip();
|
|
1113
1196
|
if (!check?.ok) {
|
|
@@ -1435,6 +1518,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1435
1518
|
if (input.command.trim() === '/sandbox') {
|
|
1436
1519
|
const config = loadConfig(directory, optionOverrides);
|
|
1437
1520
|
pushCommandText(input, output, sandboxSummary(config));
|
|
1521
|
+
await client.tui
|
|
1522
|
+
?.showToast?.({
|
|
1523
|
+
body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
|
|
1524
|
+
})
|
|
1525
|
+
?.catch?.(() => undefined);
|
|
1438
1526
|
return;
|
|
1439
1527
|
}
|
|
1440
1528
|
|
|
@@ -1453,6 +1541,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1453
1541
|
output,
|
|
1454
1542
|
'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
|
|
1455
1543
|
);
|
|
1544
|
+
await client.tui
|
|
1545
|
+
?.showToast?.({
|
|
1546
|
+
body: {
|
|
1547
|
+
title: 'Sandbox',
|
|
1548
|
+
message: 'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
|
|
1549
|
+
variant: 'warning',
|
|
1550
|
+
},
|
|
1551
|
+
})
|
|
1552
|
+
?.catch?.(() => undefined);
|
|
1456
1553
|
return;
|
|
1457
1554
|
}
|
|
1458
1555
|
|
|
@@ -1466,7 +1563,30 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1466
1563
|
return;
|
|
1467
1564
|
}
|
|
1468
1565
|
sandboxDisabled = false;
|
|
1469
|
-
|
|
1566
|
+
const config = await activeConfig();
|
|
1567
|
+
if (!config) {
|
|
1568
|
+
pushCommandText(
|
|
1569
|
+
input,
|
|
1570
|
+
output,
|
|
1571
|
+
'Sandbox re-enabled but no sandbox.json5 found — no rules active.\nCreate sandbox.json5 to enforce sandboxing.',
|
|
1572
|
+
);
|
|
1573
|
+
await client.tui
|
|
1574
|
+
?.showToast?.({
|
|
1575
|
+
body: {
|
|
1576
|
+
title: 'Sandbox',
|
|
1577
|
+
message: 'Sandbox re-enabled but no sandbox.json5 found — no rules active.',
|
|
1578
|
+
variant: 'warning',
|
|
1579
|
+
},
|
|
1580
|
+
})
|
|
1581
|
+
?.catch?.(() => undefined);
|
|
1582
|
+
} else {
|
|
1583
|
+
pushCommandText(input, output, 'Sandbox re-enabled.');
|
|
1584
|
+
await client.tui
|
|
1585
|
+
?.showToast?.({
|
|
1586
|
+
body: { title: 'Sandbox', message: 'Sandbox re-enabled.', variant: 'success' },
|
|
1587
|
+
})
|
|
1588
|
+
?.catch?.(() => undefined);
|
|
1589
|
+
}
|
|
1470
1590
|
return;
|
|
1471
1591
|
}
|
|
1472
1592
|
|
|
@@ -1482,6 +1602,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1482
1602
|
for (const path of extractCandidatePaths(shellCommand)) {
|
|
1483
1603
|
const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
|
|
1484
1604
|
if (readDecision.status === 'deny') {
|
|
1605
|
+
client.tui
|
|
1606
|
+
?.showToast?.({
|
|
1607
|
+
body: {
|
|
1608
|
+
title: 'Sandbox blocked',
|
|
1609
|
+
message: readDecision.message.slice(0, 120),
|
|
1610
|
+
variant: 'error',
|
|
1611
|
+
},
|
|
1612
|
+
})
|
|
1613
|
+
?.catch?.(() => undefined);
|
|
1485
1614
|
throw errorWithConfigPaths(directory, readDecision.message);
|
|
1486
1615
|
}
|
|
1487
1616
|
|
|
@@ -1492,6 +1621,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1492
1621
|
effectiveAllowWrite,
|
|
1493
1622
|
);
|
|
1494
1623
|
if (writeDecision.status === 'deny') {
|
|
1624
|
+
client.tui
|
|
1625
|
+
?.showToast?.({
|
|
1626
|
+
body: {
|
|
1627
|
+
title: 'Sandbox blocked',
|
|
1628
|
+
message: writeDecision.message.slice(0, 120),
|
|
1629
|
+
variant: 'error',
|
|
1630
|
+
},
|
|
1631
|
+
})
|
|
1632
|
+
?.catch?.(() => undefined);
|
|
1495
1633
|
throw errorWithConfigPaths(directory, writeDecision.message);
|
|
1496
1634
|
}
|
|
1497
1635
|
}
|
|
@@ -1507,6 +1645,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1507
1645
|
blockedDomain.reason === 'deniedDomains'
|
|
1508
1646
|
? 'is blocked by network.deniedDomains'
|
|
1509
1647
|
: 'is not in network.allowedDomains';
|
|
1648
|
+
client.tui
|
|
1649
|
+
?.showToast?.({
|
|
1650
|
+
body: {
|
|
1651
|
+
title: 'Sandbox blocked',
|
|
1652
|
+
message: `Network access denied for "${blockedDomain.domain}"`,
|
|
1653
|
+
variant: 'error',
|
|
1654
|
+
},
|
|
1655
|
+
})
|
|
1656
|
+
?.catch?.(() => undefined);
|
|
1510
1657
|
throw errorWithConfigPaths(
|
|
1511
1658
|
directory,
|
|
1512
1659
|
`Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
"ci:test": "npm test"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@jarkkojs/landstrip": "^0.11.
|
|
51
|
+
"@jarkkojs/landstrip": "^0.11.11"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@opencode-ai/plugin": "^1.17.
|
|
54
|
+
"@opencode-ai/plugin": "^1.17.6",
|
|
55
55
|
"@opentui/core": ">=0.3.4",
|
|
56
56
|
"@opentui/keymap": ">=0.3.4",
|
|
57
57
|
"@opentui/solid": ">=0.3.4",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"typescript": "^5.8.2"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@opencode-ai/plugin": "^1.17.
|
|
64
|
+
"@opencode-ai/plugin": "^1.17.6"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"@opencode-ai/plugin": {
|
|
@@ -69,6 +69,6 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"engines": {
|
|
72
|
-
"opencode": ">=1.17.
|
|
72
|
+
"opencode": ">=1.17.6"
|
|
73
73
|
}
|
|
74
74
|
}
|
package/sandbox.json
CHANGED
|
@@ -1,57 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"enabled": true,
|
|
3
3
|
"network": {
|
|
4
|
-
"
|
|
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
|
-
|
|
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/tui.ts
CHANGED
|
@@ -5,9 +5,9 @@ import type { TuiPlugin } from '@opencode-ai/plugin/tui';
|
|
|
5
5
|
|
|
6
6
|
import { binaryPath } from '@jarkkojs/landstrip';
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
|
-
import { join } from 'node:path';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
11
|
|
|
12
12
|
interface SandboxFilesystemConfig {
|
|
13
13
|
denyRead: string[];
|
|
@@ -44,38 +44,23 @@ const DEFAULT_CONFIG: SandboxConfig = {
|
|
|
44
44
|
allowLocalBinding: false,
|
|
45
45
|
allowAllUnixSockets: false,
|
|
46
46
|
allowUnixSockets: [],
|
|
47
|
-
allowedDomains: [
|
|
48
|
-
'npmjs.org',
|
|
49
|
-
'*.npmjs.org',
|
|
50
|
-
'registry.npmjs.org',
|
|
51
|
-
'registry.yarnpkg.com',
|
|
52
|
-
'pypi.org',
|
|
53
|
-
'*.pypi.org',
|
|
54
|
-
'github.com',
|
|
55
|
-
'*.github.com',
|
|
56
|
-
'api.github.com',
|
|
57
|
-
'raw.githubusercontent.com',
|
|
58
|
-
'crates.io',
|
|
59
|
-
'*.crates.io',
|
|
60
|
-
'static.crates.io',
|
|
61
|
-
],
|
|
47
|
+
allowedDomains: [],
|
|
62
48
|
deniedDomains: [],
|
|
63
49
|
},
|
|
64
50
|
filesystem: {
|
|
65
51
|
denyRead: ['/Users', '/home'],
|
|
66
|
-
allowRead: [
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
'~/.config/opencode',
|
|
70
|
-
'~/.config/git',
|
|
71
|
-
'~/.gitconfig',
|
|
72
|
-
'~/.local',
|
|
73
|
-
'~/.cargo',
|
|
74
|
-
],
|
|
75
|
-
allowWrite: ['.', '/tmp', '/dev/null'],
|
|
76
|
-
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
52
|
+
allowRead: ['.', '~/.gitconfig', '/dev/null'],
|
|
53
|
+
allowWrite: ['.', '/dev/null'],
|
|
54
|
+
denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
|
|
77
55
|
},
|
|
78
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
|
+
]);
|
|
79
64
|
|
|
80
65
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
81
66
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -147,16 +132,30 @@ function normalizeOptions(options: unknown): SandboxConfigOverrides {
|
|
|
147
132
|
return normalizeConfig(isRecord(options.config) ? options.config : options);
|
|
148
133
|
}
|
|
149
134
|
|
|
135
|
+
function mergeArray(base: string[], override?: string[]): string[] {
|
|
136
|
+
if (!override) return base;
|
|
137
|
+
return [...new Set([...base, ...override])];
|
|
138
|
+
}
|
|
139
|
+
|
|
150
140
|
function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
141
|
+
const network = overrides.network;
|
|
142
|
+
const filesystem = overrides.filesystem;
|
|
143
|
+
|
|
151
144
|
return {
|
|
152
145
|
enabled: overrides.enabled ?? base.enabled,
|
|
153
146
|
network: {
|
|
154
|
-
|
|
155
|
-
|
|
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),
|
|
156
153
|
},
|
|
157
154
|
filesystem: {
|
|
158
|
-
|
|
159
|
-
|
|
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),
|
|
160
159
|
},
|
|
161
160
|
};
|
|
162
161
|
}
|
|
@@ -168,20 +167,61 @@ function getConfigPaths(baseDirectory: string): { globalPath: string; projectPat
|
|
|
168
167
|
};
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
function readConfigFile(configPath: string): SandboxConfigOverrides {
|
|
170
|
+
function readConfigFile(configPath: string): SandboxConfigOverrides | null {
|
|
172
171
|
if (!existsSync(configPath)) return {};
|
|
173
172
|
|
|
174
173
|
try {
|
|
175
174
|
return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
176
175
|
} catch {
|
|
177
|
-
return
|
|
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
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
|
|
208
|
+
const current = readConfigFile(configPath);
|
|
209
|
+
if (current === null) {
|
|
210
|
+
throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
|
|
178
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');
|
|
179
217
|
}
|
|
180
218
|
|
|
181
219
|
function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
|
|
182
220
|
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
221
|
+
const globalConfig = readConfigFile(globalPath);
|
|
222
|
+
const projectConfig = readConfigFile(projectPath);
|
|
183
223
|
return deepMerge(
|
|
184
|
-
deepMerge(deepMerge(DEFAULT_CONFIG,
|
|
224
|
+
deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig ?? {}), projectConfig ?? {}),
|
|
185
225
|
optionOverrides,
|
|
186
226
|
);
|
|
187
227
|
}
|
|
@@ -201,7 +241,7 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
|
|
|
201
241
|
|
|
202
242
|
return [
|
|
203
243
|
`Status: ${config.enabled ? 'active' : 'disabled by config'}`,
|
|
204
|
-
`landstrip: ${
|
|
244
|
+
`landstrip package binary: ${landstripBinaryPath()}`,
|
|
205
245
|
'',
|
|
206
246
|
'Config files',
|
|
207
247
|
configPathLine('project', projectPath),
|
|
@@ -223,7 +263,150 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
|
|
|
223
263
|
].join('\n');
|
|
224
264
|
}
|
|
225
265
|
|
|
266
|
+
type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
|
|
267
|
+
|
|
268
|
+
function permissionType(permission: Record<string, unknown>, fallback = ''): string {
|
|
269
|
+
if (typeof permission.permission === 'string') return permission.permission;
|
|
270
|
+
if (typeof permission.action === 'string') return permission.action;
|
|
271
|
+
if (typeof permission.type === 'string') return permission.type;
|
|
272
|
+
return fallback;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function permissionPattern(permission: Record<string, unknown>): string | undefined {
|
|
276
|
+
const patterns = permission.patterns;
|
|
277
|
+
if (Array.isArray(patterns))
|
|
278
|
+
return patterns.find((item): item is string => typeof item === 'string');
|
|
279
|
+
|
|
280
|
+
const pattern = permission.pattern;
|
|
281
|
+
if (typeof pattern === 'string') return pattern;
|
|
282
|
+
if (Array.isArray(pattern))
|
|
283
|
+
return pattern.find((item): item is string => typeof item === 'string');
|
|
284
|
+
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function domainsFromCommand(command: string): string[] {
|
|
289
|
+
const domains = new Set<string>();
|
|
290
|
+
const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
|
|
291
|
+
let match: RegExpExecArray | null;
|
|
292
|
+
|
|
293
|
+
while ((match = urlRegex.exec(command)) !== null) domains.add(match[1]);
|
|
294
|
+
|
|
295
|
+
return [...domains];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function updateForPermission(permission: Record<string, unknown>): SandboxConfigOverrides | null {
|
|
299
|
+
const metadata = isRecord(permission.metadata) ? permission.metadata : {};
|
|
300
|
+
const type = permissionType(permission);
|
|
301
|
+
const pattern = permissionPattern(permission);
|
|
302
|
+
|
|
303
|
+
if (type === 'bash') {
|
|
304
|
+
const command = typeof metadata.command === 'string' ? metadata.command : pattern;
|
|
305
|
+
const domains = typeof command === 'string' ? domainsFromCommand(command) : [];
|
|
306
|
+
return domains.length > 0 ? { network: { allowedDomains: domains } } : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (type === 'read' || type === 'glob' || type === 'grep' || type === 'list') {
|
|
310
|
+
const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
|
|
311
|
+
return filePath ? { filesystem: { allowRead: [filePath] } } : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (type === 'edit' || type === 'write' || type === 'apply_patch') {
|
|
315
|
+
const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
|
|
316
|
+
return filePath ? { filesystem: { allowWrite: [filePath] } } : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function permissionLabel(permission: Record<string, unknown>): string {
|
|
323
|
+
const type = permissionType(permission, 'permission');
|
|
324
|
+
const title = typeof permission.title === 'string' ? permission.title : type;
|
|
325
|
+
const pattern = permissionPattern(permission);
|
|
326
|
+
return pattern ? `${title}: ${pattern}` : title;
|
|
327
|
+
}
|
|
328
|
+
|
|
226
329
|
const tui: TuiPlugin = async (api, options) => {
|
|
330
|
+
const handledPermissions = new Set<string>();
|
|
331
|
+
|
|
332
|
+
async function replyPermission(
|
|
333
|
+
permission: Record<string, unknown>,
|
|
334
|
+
choice: PermissionChoice,
|
|
335
|
+
): Promise<void> {
|
|
336
|
+
const id = typeof permission.id === 'string' ? permission.id : undefined;
|
|
337
|
+
if (!id || typeof permission.sessionID !== 'string') return;
|
|
338
|
+
|
|
339
|
+
const directory = api.state.path.directory || process.cwd();
|
|
340
|
+
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
if (choice === 'project' || choice === 'global') {
|
|
344
|
+
const update = updateForPermission(permission);
|
|
345
|
+
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await api.client.permission.reply({
|
|
349
|
+
requestID: id,
|
|
350
|
+
reply: choice === 'reject' ? 'reject' : choice === 'once' ? 'once' : 'always',
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
api.ui.toast({
|
|
354
|
+
title: 'Sandbox',
|
|
355
|
+
message: choice === 'reject' ? 'Permission rejected' : `Permission allowed for ${choice}`,
|
|
356
|
+
variant: choice === 'reject' ? 'warning' : 'success',
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
api.ui.toast({
|
|
360
|
+
title: 'Sandbox',
|
|
361
|
+
message: 'Permission was already handled or could not be updated',
|
|
362
|
+
variant: 'warning',
|
|
363
|
+
});
|
|
364
|
+
} finally {
|
|
365
|
+
api.ui.dialog.clear();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function showPermission(permission: Record<string, unknown>): void {
|
|
370
|
+
const id = typeof permission.id === 'string' ? permission.id : undefined;
|
|
371
|
+
if (!id || handledPermissions.has(id)) return;
|
|
372
|
+
handledPermissions.add(id);
|
|
373
|
+
|
|
374
|
+
api.ui.dialog.replace(
|
|
375
|
+
() =>
|
|
376
|
+
api.ui.DialogSelect<PermissionChoice>({
|
|
377
|
+
title: 'Sandbox Permission',
|
|
378
|
+
placeholder: permissionLabel(permission),
|
|
379
|
+
options: [
|
|
380
|
+
{ title: 'Allow once', value: 'once', description: 'Approve only this request' },
|
|
381
|
+
{
|
|
382
|
+
title: 'Allow for session',
|
|
383
|
+
value: 'session',
|
|
384
|
+
description: 'Use OpenCode session approval for matching requests',
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
title: 'Allow for project',
|
|
388
|
+
value: 'project',
|
|
389
|
+
description: 'Persist to .opencode/sandbox.json and approve this session',
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
title: 'Allow globally',
|
|
393
|
+
value: 'global',
|
|
394
|
+
description: 'Persist to ~/.config/opencode/sandbox.json and approve this session',
|
|
395
|
+
},
|
|
396
|
+
{ title: 'Reject', value: 'reject', description: 'Deny this request' },
|
|
397
|
+
],
|
|
398
|
+
onSelect: (option) => {
|
|
399
|
+
void replyPermission(permission, option.value);
|
|
400
|
+
},
|
|
401
|
+
}),
|
|
402
|
+
() => api.ui.dialog.clear(),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
api.event.on('permission.asked', (event) => {
|
|
407
|
+
showPermission(event.properties as Record<string, unknown>);
|
|
408
|
+
});
|
|
409
|
+
|
|
227
410
|
const showSandbox = () => {
|
|
228
411
|
const directory = api.state.path.directory || process.cwd();
|
|
229
412
|
const message = sandboxSummary(directory, normalizeOptions(options));
|
|
@@ -239,32 +422,71 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
239
422
|
);
|
|
240
423
|
};
|
|
241
424
|
|
|
425
|
+
const executeServerCommand = async (command: string): Promise<boolean> => {
|
|
426
|
+
await api.client.tui.executeCommand({ command });
|
|
427
|
+
return true;
|
|
428
|
+
};
|
|
429
|
+
|
|
242
430
|
api.keymap.registerLayer({
|
|
243
431
|
commands: [
|
|
244
432
|
{
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
desc: 'Show landstrip sandbox status and rules',
|
|
249
|
-
description: 'Show landstrip sandbox status and rules',
|
|
433
|
+
name: 'sandbox',
|
|
434
|
+
title: 'Sandbox',
|
|
435
|
+
description: 'Show sandbox configuration',
|
|
250
436
|
category: 'Sandbox',
|
|
251
437
|
suggested: true,
|
|
252
|
-
|
|
438
|
+
slash: { name: 'sandbox' },
|
|
253
439
|
run: showSandbox,
|
|
254
440
|
},
|
|
441
|
+
{
|
|
442
|
+
name: 'sandbox-disable',
|
|
443
|
+
title: 'Disable sandbox',
|
|
444
|
+
description: 'Disable sandbox for this session',
|
|
445
|
+
category: 'Sandbox',
|
|
446
|
+
suggested: true,
|
|
447
|
+
slash: { name: 'sandbox-disable' },
|
|
448
|
+
run: () => executeServerCommand('sandbox-disable'),
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: 'sandbox-enable',
|
|
452
|
+
title: 'Enable sandbox',
|
|
453
|
+
description: 'Re-enable sandbox for this session',
|
|
454
|
+
category: 'Sandbox',
|
|
455
|
+
suggested: true,
|
|
456
|
+
slash: { name: 'sandbox-enable' },
|
|
457
|
+
run: () => executeServerCommand('sandbox-enable'),
|
|
458
|
+
},
|
|
255
459
|
],
|
|
256
460
|
});
|
|
257
461
|
|
|
258
462
|
api.command?.register(() => [
|
|
259
463
|
{
|
|
260
464
|
title: 'Sandbox',
|
|
261
|
-
value: '
|
|
465
|
+
value: 'sandbox',
|
|
262
466
|
description: 'Show sandbox configuration',
|
|
263
467
|
category: 'Sandbox',
|
|
264
468
|
suggested: true,
|
|
265
469
|
slash: { name: 'sandbox' },
|
|
266
470
|
onSelect: showSandbox,
|
|
267
471
|
},
|
|
472
|
+
{
|
|
473
|
+
title: 'Disable sandbox',
|
|
474
|
+
value: 'sandbox-disable',
|
|
475
|
+
description: 'Disable sandbox for this session',
|
|
476
|
+
category: 'Sandbox',
|
|
477
|
+
suggested: true,
|
|
478
|
+
slash: { name: 'sandbox-disable' },
|
|
479
|
+
onSelect: () => executeServerCommand('sandbox-disable'),
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
title: 'Enable sandbox',
|
|
483
|
+
value: 'sandbox-enable',
|
|
484
|
+
description: 'Re-enable sandbox for this session',
|
|
485
|
+
category: 'Sandbox',
|
|
486
|
+
suggested: true,
|
|
487
|
+
slash: { name: 'sandbox-enable' },
|
|
488
|
+
onSelect: () => executeServerCommand('sandbox-enable'),
|
|
489
|
+
},
|
|
268
490
|
]);
|
|
269
491
|
};
|
|
270
492
|
|