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/tui.ts CHANGED
@@ -1,190 +1,35 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
- import type { TuiPlugin } from '@opencode-ai/plugin/tui';
5
-
6
- import { binaryPath } from '@jarkkojs/landstrip';
7
-
8
- import { existsSync, readFileSync } from 'node:fs';
9
- import { homedir } from 'node:os';
10
- import { join } from 'node:path';
11
-
12
- interface SandboxFilesystemConfig {
13
- denyRead: string[];
14
- allowRead: string[];
15
- allowWrite: string[];
16
- denyWrite: string[];
17
- }
18
-
19
- interface SandboxNetworkConfig {
20
- allowNetwork: boolean;
21
- allowLocalBinding: boolean;
22
- allowAllUnixSockets: boolean;
23
- allowUnixSockets: string[];
24
- allowedDomains: string[];
25
- deniedDomains: string[];
26
- }
27
-
28
- interface SandboxConfig {
29
- enabled: boolean;
30
- network: SandboxNetworkConfig;
31
- filesystem: SandboxFilesystemConfig;
32
- }
33
-
34
- interface SandboxConfigOverrides {
35
- enabled?: boolean;
36
- network?: Partial<SandboxNetworkConfig>;
37
- filesystem?: Partial<SandboxFilesystemConfig>;
38
- }
39
-
40
- const DEFAULT_CONFIG: SandboxConfig = {
41
- enabled: true,
42
- network: {
43
- allowNetwork: false,
44
- allowLocalBinding: false,
45
- allowAllUnixSockets: false,
46
- allowUnixSockets: [],
47
- allowedDomains: [
48
- '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
- ],
62
- deniedDomains: [],
63
- },
64
- filesystem: {
65
- denyRead: ['/Users', '/home'],
66
- allowRead: [
67
- '.',
68
- '/dev/null',
69
- '~/.config/opencode',
70
- '~/.config/git',
71
- '~/.gitconfig',
72
- '~/.local',
73
- '~/.cargo',
74
- ],
75
- allowWrite: ['.', '/tmp', '/dev/null'],
76
- denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
77
- },
78
- };
79
-
80
- function isRecord(value: unknown): value is Record<string, unknown> {
81
- return typeof value === 'object' && value !== null && !Array.isArray(value);
82
- }
83
-
84
- function stringArray(value: unknown): string[] | undefined {
85
- if (!Array.isArray(value)) return undefined;
86
- return value.every((item) => typeof item === 'string') ? [...value] : undefined;
87
- }
88
-
89
- function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
90
- if (!isRecord(value)) return undefined;
91
-
92
- const config: Partial<SandboxNetworkConfig> = {};
93
- if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
94
- if (typeof value.allowLocalBinding === 'boolean')
95
- config.allowLocalBinding = value.allowLocalBinding;
96
- if (typeof value.allowAllUnixSockets === 'boolean')
97
- config.allowAllUnixSockets = value.allowAllUnixSockets;
98
-
99
- const allowUnixSockets = stringArray(value.allowUnixSockets);
100
- if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
101
-
102
- const allowedDomains = stringArray(value.allowedDomains);
103
- if (allowedDomains) config.allowedDomains = allowedDomains;
104
-
105
- const deniedDomains = stringArray(value.deniedDomains);
106
- if (deniedDomains) config.deniedDomains = deniedDomains;
107
-
108
- return config;
109
- }
110
-
111
- function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
112
- if (!isRecord(value)) return undefined;
113
-
114
- const config: Partial<SandboxFilesystemConfig> = {};
115
- const denyRead = stringArray(value.denyRead);
116
- if (denyRead) config.denyRead = denyRead;
117
-
118
- const allowRead = stringArray(value.allowRead);
119
- if (allowRead) config.allowRead = allowRead;
120
-
121
- const allowWrite = stringArray(value.allowWrite);
122
- if (allowWrite) config.allowWrite = allowWrite;
123
-
124
- const denyWrite = stringArray(value.denyWrite);
125
- if (denyWrite) config.denyWrite = denyWrite;
126
-
127
- return config;
128
- }
129
-
130
- function normalizeConfig(value: unknown): SandboxConfigOverrides {
131
- if (!isRecord(value)) return {};
132
-
133
- const config: SandboxConfigOverrides = {};
134
- if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
135
-
136
- const network = normalizeNetworkConfig(value.network);
137
- if (network) config.network = network;
138
-
139
- const filesystem = normalizeFilesystemConfig(value.filesystem);
140
- if (filesystem) config.filesystem = filesystem;
141
-
142
- return config;
143
- }
144
-
145
- function normalizeOptions(options: unknown): SandboxConfigOverrides {
146
- if (!isRecord(options)) return {};
147
- return normalizeConfig(isRecord(options.config) ? options.config : options);
148
- }
149
-
150
- function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
151
- return {
152
- enabled: overrides.enabled ?? base.enabled,
153
- network: {
154
- ...base.network,
155
- ...overrides.network,
156
- },
157
- filesystem: {
158
- ...base.filesystem,
159
- ...overrides.filesystem,
160
- },
161
- };
162
- }
163
-
164
- function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
165
- return {
166
- globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
167
- projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
168
- };
169
- }
170
-
171
- function readConfigFile(configPath: string): SandboxConfigOverrides {
172
- if (!existsSync(configPath)) return {};
173
-
174
- try {
175
- return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
176
- } catch {
177
- return {};
178
- }
4
+ import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
5
+
6
+ import { existsSync } from 'node:fs';
7
+
8
+ import {
9
+ type SandboxConfigOverrides,
10
+ getConfigPaths,
11
+ landstripBinaryPath,
12
+ loadConfig,
13
+ normalizeOptions,
14
+ permissionLabel,
15
+ permissionResource,
16
+ updateForPermission,
17
+ writeConfigFile,
18
+ } from './shared.js';
19
+
20
+ // The shape shared by the `permission.asked` event payload and the entries
21
+ // returned from `api.state.session.permission()`. Both carry `permission`
22
+ // (the kind), `patterns`, and `tool.callID`; neither carries a `title`.
23
+ interface PendingPermission {
24
+ id: string;
25
+ sessionID: string;
26
+ permission: string;
27
+ patterns: string[];
28
+ metadata: Record<string, unknown>;
29
+ tool?: { callID: string };
179
30
  }
180
31
 
181
- function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
182
- const { globalPath, projectPath } = getConfigPaths(baseDirectory);
183
- return deepMerge(
184
- deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
185
- optionOverrides,
186
- );
187
- }
32
+ type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
188
33
 
189
34
  function list(values: string[]): string {
190
35
  return values.join(', ') || '(none)';
@@ -201,7 +46,7 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
201
46
 
202
47
  return [
203
48
  `Status: ${config.enabled ? 'active' : 'disabled by config'}`,
204
- `landstrip: ${binaryPath()}`,
49
+ `landstrip package binary: ${landstripBinaryPath()}`,
205
50
  '',
206
51
  'Config files',
207
52
  configPathLine('project', projectPath),
@@ -223,10 +68,170 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
223
68
  ].join('\n');
224
69
  }
225
70
 
226
- const tui: TuiPlugin = async (api, options) => {
71
+ function asRecord(permission: PendingPermission): Record<string, unknown> {
72
+ return permission as unknown as Record<string, unknown>;
73
+ }
74
+
75
+ function permissionDetail(permission: PendingPermission): string {
76
+ const label = permissionLabel(asRecord(permission));
77
+ const resource = permissionResource(asRecord(permission));
78
+ return resource && !label.includes(resource) ? `${label}: ${resource}` : label;
79
+ }
80
+
81
+ const tui: TuiPlugin = async (api, options, meta) => {
82
+ const optionOverrides = normalizeOptions(options);
83
+
84
+ // Permission requests can arrive twice (the live event and a reconnect replay
85
+ // of `api.state`), so `resolved` tracks ids we have already answered and
86
+ // `activeId` guards against stacking a second sandbox dialog on the first.
87
+ const resolved = new Set<string>();
88
+ const queue: PendingPermission[] = [];
89
+ let activeId: string | undefined;
90
+
91
+ function pump(): void {
92
+ if (activeId !== undefined) return;
93
+ let next = queue.shift();
94
+ while (next && resolved.has(next.id)) next = queue.shift();
95
+ if (!next) return;
96
+ showPermission(next);
97
+ }
98
+
99
+ function enqueue(permission: PendingPermission): void {
100
+ if (!permission.id || resolved.has(permission.id)) return;
101
+ if (activeId === permission.id) return;
102
+ if (queue.some((item) => item.id === permission.id)) return;
103
+ queue.push(permission);
104
+ pump();
105
+ }
106
+
107
+ // Safety net for missed/late events and reconnects: fold whatever the host
108
+ // still considers pending for this session back into the queue.
109
+ function reconcile(sessionID: string): void {
110
+ for (const pending of api.state.session.permission(sessionID)) {
111
+ enqueue(pending as PendingPermission);
112
+ }
113
+ }
114
+
115
+ function finishActive(id: string): void {
116
+ resolved.add(id);
117
+ if (activeId === id) {
118
+ activeId = undefined;
119
+ api.ui.dialog.clear();
120
+ }
121
+ pump();
122
+ }
123
+
124
+ async function replyPermission(
125
+ permission: PendingPermission,
126
+ choice: PermissionChoice,
127
+ ): Promise<void> {
128
+ const { id, sessionID } = permission;
129
+ if (!id || !sessionID) return;
130
+
131
+ const directory = api.state.path.directory || process.cwd();
132
+ const { globalPath, projectPath } = getConfigPaths(directory);
133
+
134
+ try {
135
+ if (choice === 'project' || choice === 'global') {
136
+ const update = updateForPermission(asRecord(permission));
137
+ if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
138
+ }
139
+
140
+ await api.client.permission.reply({
141
+ requestID: id,
142
+ reply: choice === 'reject' ? 'reject' : choice === 'once' ? 'once' : 'always',
143
+ });
144
+
145
+ api.ui.toast({
146
+ title: 'Sandbox',
147
+ message: choice === 'reject' ? 'Permission rejected' : `Permission allowed for ${choice}`,
148
+ variant: choice === 'reject' ? 'warning' : 'success',
149
+ });
150
+ } catch {
151
+ api.ui.toast({
152
+ title: 'Sandbox',
153
+ message: 'Permission was already handled or could not be updated',
154
+ variant: 'warning',
155
+ });
156
+ } finally {
157
+ finishActive(id);
158
+ }
159
+ }
160
+
161
+ function showPermission(permission: PendingPermission): void {
162
+ activeId = permission.id;
163
+
164
+ void api.attention.notify({
165
+ title: 'Sandbox permission',
166
+ message: permissionDetail(permission),
167
+ sound: { name: 'permission' },
168
+ notification: true,
169
+ });
170
+
171
+ api.ui.dialog.replace(
172
+ () =>
173
+ api.ui.DialogSelect<PermissionChoice>({
174
+ title: 'Sandbox Permission',
175
+ placeholder: permissionDetail(permission),
176
+ options: [
177
+ {
178
+ title: 'Allow once',
179
+ value: 'once',
180
+ category: 'This request',
181
+ description: 'Approve only this request',
182
+ },
183
+ {
184
+ title: 'Allow for session',
185
+ value: 'session',
186
+ category: 'This request',
187
+ description: 'Use OpenCode session approval for matching requests',
188
+ },
189
+ {
190
+ title: 'Allow for project',
191
+ value: 'project',
192
+ category: 'Persist to sandbox.json',
193
+ description: 'Persist to .opencode/sandbox.json and approve this session',
194
+ },
195
+ {
196
+ title: 'Allow globally',
197
+ value: 'global',
198
+ category: 'Persist to sandbox.json',
199
+ description: 'Persist to ~/.config/opencode/sandbox.json and approve this session',
200
+ },
201
+ {
202
+ title: 'Reject',
203
+ value: 'reject',
204
+ category: 'Deny',
205
+ description: 'Deny this request',
206
+ },
207
+ ],
208
+ onSelect: (option) => {
209
+ void replyPermission(permission, option.value);
210
+ },
211
+ }),
212
+ () => {
213
+ // Dialog dismissed (esc) without a choice: drop our hold so the next
214
+ // pending permission can surface, but leave it unresolved upstream.
215
+ if (activeId === permission.id) activeId = undefined;
216
+ api.ui.dialog.clear();
217
+ pump();
218
+ },
219
+ );
220
+ }
221
+
222
+ const unsubscribeAsked = api.event.on('permission.asked', (event) => {
223
+ const pending = event.properties as PendingPermission;
224
+ enqueue(pending);
225
+ reconcile(pending.sessionID);
226
+ });
227
+
228
+ const unsubscribeReplied = api.event.on('permission.replied', (event) => {
229
+ finishActive(event.properties.requestID);
230
+ });
231
+
227
232
  const showSandbox = () => {
228
233
  const directory = api.state.path.directory || process.cwd();
229
- const message = sandboxSummary(directory, normalizeOptions(options));
234
+ const message = sandboxSummary(directory, optionOverrides);
230
235
 
231
236
  api.ui.dialog.replace(
232
237
  () =>
@@ -239,33 +244,93 @@ const tui: TuiPlugin = async (api, options) => {
239
244
  );
240
245
  };
241
246
 
247
+ const executeServerCommand = async (command: string): Promise<boolean> => {
248
+ await api.client.tui.executeCommand({ command });
249
+ return true;
250
+ };
251
+
242
252
  api.keymap.registerLayer({
243
253
  commands: [
244
254
  {
245
255
  namespace: 'palette',
246
- name: 'landstrip.sandbox.show',
247
- title: 'Show sandbox configuration',
248
- desc: 'Show landstrip sandbox status and rules',
249
- description: 'Show landstrip sandbox status and rules',
256
+ name: 'sandbox',
257
+ title: 'Sandbox',
258
+ desc: 'Show sandbox configuration',
250
259
  category: 'Sandbox',
251
260
  suggested: true,
252
261
  slashName: 'sandbox',
253
262
  run: showSandbox,
254
263
  },
264
+ {
265
+ namespace: 'palette',
266
+ name: 'sandbox-disable',
267
+ title: 'Disable sandbox',
268
+ desc: 'Disable sandbox for this session',
269
+ category: 'Sandbox',
270
+ suggested: true,
271
+ slashName: 'sandbox-disable',
272
+ run: () => executeServerCommand('sandbox-disable'),
273
+ },
274
+ {
275
+ namespace: 'palette',
276
+ name: 'sandbox-enable',
277
+ title: 'Enable sandbox',
278
+ desc: 'Re-enable sandbox for this session',
279
+ category: 'Sandbox',
280
+ suggested: true,
281
+ slashName: 'sandbox-enable',
282
+ run: () => executeServerCommand('sandbox-enable'),
283
+ },
255
284
  ],
256
285
  });
257
286
 
258
- api.command?.register(() => [
259
- {
260
- title: 'Sandbox',
261
- value: 'landstrip.sandbox.show',
262
- description: 'Show sandbox configuration',
263
- category: 'Sandbox',
264
- suggested: true,
265
- slash: { name: 'sandbox' },
266
- onSelect: showSandbox,
267
- },
268
- ]);
287
+ // Persistent status badge in the prompt area. It needs the host's Solid
288
+ // runtime, imported defensively so a host that resolves plugin imports
289
+ // differently still loads the plugin — the badge just stays absent there.
290
+ try {
291
+ const { jsx } = await import('@opentui/solid/jsx-runtime');
292
+ const statusBadge = (ctx: TuiSlotContext) => {
293
+ const directory = api.state.path.directory || process.cwd();
294
+ const config = loadConfig(directory, optionOverrides);
295
+ const theme = ctx.theme.current;
296
+
297
+ if (!config.enabled) return jsx('text', { fg: theme.textMuted, children: 'sandbox off' });
298
+
299
+ const open = config.network.allowNetwork;
300
+ return jsx('text', {
301
+ fg: open ? theme.warning : theme.success,
302
+ children: `sandbox · ${open ? 'net open' : 'net proxied'}`,
303
+ });
304
+ };
305
+
306
+ const statusSlot: TuiSlotPlugin = {
307
+ slots: {
308
+ home_prompt_right: (ctx) => statusBadge(ctx),
309
+ session_prompt_right: (ctx) => statusBadge(ctx),
310
+ },
311
+ };
312
+ api.slots.register(statusSlot);
313
+ } catch {
314
+ // Solid runtime unavailable on this host — skip the status badge.
315
+ }
316
+
317
+ // First-run onboarding: a single quiet pointer to the default-strict policy
318
+ // and the inspector command. `meta.state` flags a freshly installed plugin;
319
+ // the kv flag keeps it from repeating across reloads.
320
+ if (meta.state === 'first' && !api.kv.get<boolean>('onboarded', false)) {
321
+ api.kv.set('onboarded', true);
322
+ api.ui.toast({
323
+ title: 'Sandbox active',
324
+ message: 'Landlock policy is on (default strict). Run /sandbox to inspect it.',
325
+ variant: 'info',
326
+ duration: 8000,
327
+ });
328
+ }
329
+
330
+ api.lifecycle.onDispose(() => {
331
+ unsubscribeAsked();
332
+ unsubscribeReplied();
333
+ });
269
334
  };
270
335
 
271
336
  export { tui };