opencode-landstrip 0.18.0 → 0.18.1

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 (3) hide show
  1. package/README.md +7 -4
  2. package/package.json +2 -2
  3. package/tui.ts +196 -104
package/README.md CHANGED
@@ -60,10 +60,13 @@ sets it, otherwise to the global config.
60
60
 
61
61
  ## Behavior
62
62
 
63
- When OpenCode requests a sandboxed permission, the plugin sends a host
64
- notification and opens a dialog. The user can allow once, allow for the session,
65
- persist for the project or globally, or reject. The dialog shows the exact path
66
- or domain being approved.
63
+ When OpenCode asks whether the agent may invoke a sandboxed tool, the plugin
64
+ shows an agent-permission prompt. If a running command instead reaches a
65
+ filesystem or network boundary, the plugin shows a distinct **Sandbox access
66
+ blocked** prompt with the exact path or network target. Both use OpenCode's
67
+ permission-prompt styling, while retaining their separate choices and behavior.
68
+ The user can allow once, allow for the session, persist an approval for the
69
+ project or globally, or reject the request.
67
70
 
68
71
  Project approvals are written to `.opencode/sandbox.json`; global approvals go
69
72
  to `~/.config/opencode/sandbox.json`. A newly created global file starts with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -48,7 +48,7 @@
48
48
  "ci:test": "npm test"
49
49
  },
50
50
  "dependencies": {
51
- "@landstrip/landstrip": "^0.18.0",
51
+ "@landstrip/landstrip": "^0.18.1",
52
52
  "@opentui/solid": ">=0.3.4",
53
53
  "solid-js": "1.9.12"
54
54
  },
package/tui.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
- import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
5
4
  import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
6
5
 
6
+ import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
7
+ import { useTerminalDimensions } from '@opentui/solid';
8
+ import { jsx, jsxs } from '@opentui/solid/jsx-runtime';
9
+ import { createSignal, onCleanup } from 'solid-js';
10
+
7
11
  import {
8
12
  controlResponseLine,
9
13
  getConfigPaths,
@@ -42,6 +46,33 @@ type QueryChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
42
46
  // address:port" the way allowRead/allowWrite express a path.
43
47
  type NetworkQueryChoice = 'once' | 'session' | 'deny';
44
48
 
49
+ interface PromptOption<Value extends string> {
50
+ label: string;
51
+ value: Value;
52
+ }
53
+
54
+ interface PermissionPromptProps<Value extends string> {
55
+ title: string;
56
+ icon: string;
57
+ detail: string;
58
+ options: readonly PromptOption<Value>[];
59
+ onSelect: (value: Value) => void;
60
+ }
61
+
62
+ const promptBorderChars = {
63
+ topLeft: '',
64
+ bottomLeft: '',
65
+ vertical: '┃',
66
+ topRight: '',
67
+ bottomRight: '',
68
+ horizontal: ' ',
69
+ bottomT: '',
70
+ topT: '',
71
+ cross: '',
72
+ leftT: '',
73
+ rightT: '',
74
+ };
75
+
45
76
  // A landstrip filesystem query (read or write) held pending over the fd-3
46
77
  // socket. It shares the dialog stack with permission prompts so the two never
47
78
  // overlap, hence the common `id`/`kind` shape.
@@ -88,6 +119,134 @@ function permissionDetail(permission: PendingPermission): string {
88
119
  const tui: TuiPlugin = async (api, options, meta) => {
89
120
  const optionOverrides = normalizeOptions(options);
90
121
 
122
+ function renderPermissionPrompt<Value extends string>(props: PermissionPromptProps<Value>) {
123
+ const theme = api.theme.current;
124
+ const dimensions = useTerminalDimensions();
125
+ const [selected, setSelected] = createSignal(0);
126
+
127
+ function move(direction: number): void {
128
+ setSelected((index) => (index + direction + props.options.length) % props.options.length);
129
+ }
130
+
131
+ function submit(): void {
132
+ const option = props.options[selected()];
133
+ if (option) props.onSelect(option.value);
134
+ }
135
+
136
+ const unregister = api.keymap.registerLayer({
137
+ priority: 1000,
138
+ bindings: [
139
+ { key: 'left', cmd: () => move(-1) },
140
+ { key: 'h', cmd: () => move(-1) },
141
+ { key: 'right', cmd: () => move(1) },
142
+ { key: 'l', cmd: () => move(1) },
143
+ { key: 'return', cmd: submit },
144
+ ],
145
+ });
146
+ onCleanup(unregister);
147
+
148
+ const optionButtons = props.options.map((option, index) =>
149
+ jsx('box', {
150
+ paddingLeft: 1,
151
+ paddingRight: 1,
152
+ get backgroundColor() {
153
+ return selected() === index ? theme.warning : theme.backgroundMenu;
154
+ },
155
+ onMouseOver: () => setSelected(index),
156
+ onMouseUp: () => {
157
+ setSelected(index);
158
+ props.onSelect(option.value);
159
+ },
160
+ children: jsx('text', {
161
+ get fg() {
162
+ return selected() === index ? theme.selectedListItemText : theme.textMuted;
163
+ },
164
+ children: option.label,
165
+ }),
166
+ }),
167
+ );
168
+
169
+ return jsxs('box', {
170
+ border: ['left'],
171
+ borderColor: theme.warning,
172
+ customBorderChars: promptBorderChars,
173
+ backgroundColor: theme.backgroundPanel,
174
+ children: [
175
+ jsxs('box', {
176
+ gap: 1,
177
+ paddingLeft: 2,
178
+ paddingRight: 3,
179
+ paddingTop: 1,
180
+ paddingBottom: 1,
181
+ children: [
182
+ jsxs('box', {
183
+ flexDirection: 'row',
184
+ gap: 1,
185
+ children: [
186
+ jsx('text', { fg: theme.warning, children: '△' }),
187
+ jsx('text', { fg: theme.text, children: props.title }),
188
+ ],
189
+ }),
190
+ jsxs('box', {
191
+ flexDirection: 'row',
192
+ gap: 1,
193
+ paddingLeft: 2,
194
+ children: [
195
+ jsx('text', { fg: theme.textMuted, flexShrink: 0, children: props.icon }),
196
+ jsx('text', { fg: theme.text, wrapMode: 'word', children: props.detail }),
197
+ ],
198
+ }),
199
+ ],
200
+ }),
201
+ jsxs('box', {
202
+ get flexDirection() {
203
+ return dimensions().width < 100 ? 'column' : 'row';
204
+ },
205
+ flexShrink: 0,
206
+ gap: 1,
207
+ paddingTop: 1,
208
+ paddingLeft: 2,
209
+ paddingRight: 3,
210
+ paddingBottom: 1,
211
+ backgroundColor: theme.backgroundElement,
212
+ get justifyContent() {
213
+ return dimensions().width < 100 ? 'flex-start' : 'space-between';
214
+ },
215
+ get alignItems() {
216
+ return dimensions().width < 100 ? 'flex-start' : 'center';
217
+ },
218
+ children: [
219
+ jsx('box', {
220
+ flexDirection: 'row',
221
+ gap: 1,
222
+ children: optionButtons,
223
+ }),
224
+ jsxs('box', {
225
+ flexDirection: 'row',
226
+ gap: 2,
227
+ children: [
228
+ jsxs('text', {
229
+ fg: theme.text,
230
+ children: [
231
+ '⇆ ',
232
+ jsx('span', { style: { fg: theme.textMuted }, children: 'select' }),
233
+ ],
234
+ }),
235
+ jsxs('text', {
236
+ fg: theme.text,
237
+ children: [
238
+ 'enter ',
239
+ jsx('span', { style: { fg: theme.textMuted }, children: 'confirm' }),
240
+ ],
241
+ }),
242
+ ],
243
+ }),
244
+ ],
245
+ }),
246
+ ],
247
+ });
248
+ }
249
+
91
250
  // Permission requests can arrive twice (the live event and a reconnect replay
92
251
  // of `api.state`), so `resolved` tracks ids we have already answered and
93
252
  // `activeId` guards against stacking a second sandbox dialog on the first.
@@ -205,43 +364,19 @@ const tui: TuiPlugin = async (api, options, meta) => {
205
364
 
206
365
  api.ui.dialog.replace(
207
366
  () =>
208
- api.ui.DialogSelect<PermissionChoice>({
209
- title: 'Sandbox Permission',
210
- placeholder: permissionDetail(permission),
367
+ renderPermissionPrompt<PermissionChoice>({
368
+ title: 'Permission required',
369
+ icon: '⚙',
370
+ detail: permissionDetail(permission),
211
371
  options: [
212
- {
213
- title: 'Allow once',
214
- value: 'once',
215
- category: 'This request',
216
- description: 'Approve only this request',
217
- },
218
- {
219
- title: 'Allow for session',
220
- value: 'session',
221
- category: 'This request',
222
- description: 'Use OpenCode session approval for matching requests',
223
- },
224
- {
225
- title: 'Allow for project',
226
- value: 'project',
227
- category: 'Persist to sandbox.json',
228
- description: 'Persist to .opencode/sandbox.json and approve this session',
229
- },
230
- {
231
- title: 'Allow globally',
232
- value: 'global',
233
- category: 'Persist to sandbox.json',
234
- description: 'Persist to ~/.config/opencode/sandbox.json and approve this session',
235
- },
236
- {
237
- title: 'Reject',
238
- value: 'reject',
239
- category: 'Deny',
240
- description: 'Deny this request',
241
- },
372
+ { label: 'Allow once', value: 'once' },
373
+ { label: 'Allow for session', value: 'session' },
374
+ { label: 'Allow for project', value: 'project' },
375
+ { label: 'Allow globally', value: 'global' },
376
+ { label: 'Reject', value: 'reject' },
242
377
  ],
243
- onSelect: (option) => {
244
- void replyPermission(permission, option.value);
378
+ onSelect: (choice) => {
379
+ void replyPermission(permission, choice);
245
380
  },
246
381
  }),
247
382
  () => {
@@ -254,6 +389,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
254
389
  queueMicrotask(pump);
255
390
  },
256
391
  );
392
+ api.ui.dialog.setSize('xlarge');
257
393
  }
258
394
 
259
395
  function respondQuery(socket: NetSocket, queryId: string, action: 'allow' | 'deny'): void {
@@ -307,13 +443,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
307
443
  function showFsQuery(entry: FsQueryEntry): void {
308
444
  activeId = entry.id;
309
445
  const verb = entry.operation === 'read' ? 'Read' : 'Write';
310
- const noun = entry.operation;
311
- const listName = entry.operation === 'read' ? 'allowRead' : 'allowWrite';
312
- const directory = api.state.path.directory || process.cwd();
313
- const scope = sessionScopeFor(entry.path, directory);
314
446
 
315
447
  void api.attention.notify({
316
- title: `Sandbox ${noun} blocked`,
448
+ title: `Sandbox ${entry.operation} blocked`,
317
449
  message: entry.path,
318
450
  sound: { name: 'permission' },
319
451
  notification: true,
@@ -326,50 +458,27 @@ const tui: TuiPlugin = async (api, options, meta) => {
326
458
 
327
459
  api.ui.dialog.replace(
328
460
  () =>
329
- api.ui.DialogSelect<QueryChoice>({
330
- title: `Sandbox ${verb} Blocked`,
331
- placeholder: `${verb} blocked: ${entry.path}`,
461
+ renderPermissionPrompt<QueryChoice>({
462
+ title: 'Sandbox access blocked',
463
+ icon: '→',
464
+ detail: `${verb}: ${entry.path}`,
332
465
  options: [
333
- {
334
- title: 'Allow once',
335
- value: 'once',
336
- category: `This ${noun}`,
337
- description: `Permit only this ${noun}`,
338
- },
339
- {
340
- title: 'Allow for session',
341
- value: 'session',
342
- category: `This ${noun}`,
343
- description: `Permit ${noun}s under ${scope} for this session`,
344
- },
345
- {
346
- title: 'Allow for project',
347
- value: 'project',
348
- category: 'Persist to sandbox.json',
349
- description: `Add ${scope} to .opencode/sandbox.json ${listName}`,
350
- },
351
- {
352
- title: 'Allow globally',
353
- value: 'global',
354
- category: 'Persist to sandbox.json',
355
- description: `Add ${scope} to ~/.config/opencode/sandbox.json ${listName}`,
356
- },
357
- {
358
- title: 'Deny',
359
- value: 'deny',
360
- category: 'Deny',
361
- description: `Block this ${noun}`,
362
- },
466
+ { label: 'Allow once', value: 'once' },
467
+ { label: 'Allow for session', value: 'session' },
468
+ { label: 'Allow for project', value: 'project' },
469
+ { label: 'Allow globally', value: 'global' },
470
+ { label: 'Deny', value: 'deny' },
363
471
  ],
364
- onSelect: (option) => {
472
+ onSelect: (choice) => {
365
473
  selectionMade = true;
366
- resolveFsQuery(entry, option.value);
474
+ resolveFsQuery(entry, choice);
367
475
  },
368
476
  }),
369
477
  () => {
370
478
  if (!selectionMade) resolveFsQuery(entry, 'deny');
371
479
  },
372
480
  );
481
+ api.ui.dialog.setSize('xlarge');
373
482
  }
374
483
 
375
484
  function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
@@ -409,38 +518,25 @@ const tui: TuiPlugin = async (api, options, meta) => {
409
518
 
410
519
  api.ui.dialog.replace(
411
520
  () =>
412
- api.ui.DialogSelect<NetworkQueryChoice>({
413
- title: 'Sandbox Network Blocked',
414
- placeholder: `${entry.operation} blocked: ${entry.target}`,
521
+ renderPermissionPrompt<NetworkQueryChoice>({
522
+ title: 'Sandbox access blocked',
523
+ icon: '%',
524
+ detail: `${entry.operation}: ${entry.target}`,
415
525
  options: [
416
- {
417
- title: 'Allow once',
418
- value: 'once',
419
- category: `This ${entry.operation}`,
420
- description: `Permit only this ${entry.operation}`,
421
- },
422
- {
423
- title: 'Allow for session',
424
- value: 'session',
425
- category: `This ${entry.operation}`,
426
- description: `Permit ${entry.target} for this session`,
427
- },
428
- {
429
- title: 'Deny',
430
- value: 'deny',
431
- category: 'Deny',
432
- description: `Block this ${entry.operation}`,
433
- },
526
+ { label: 'Allow once', value: 'once' },
527
+ { label: 'Allow for session', value: 'session' },
528
+ { label: 'Deny', value: 'deny' },
434
529
  ],
435
- onSelect: (option) => {
530
+ onSelect: (choice) => {
436
531
  selectionMade = true;
437
- resolveNetworkQuery(entry, option.value);
532
+ resolveNetworkQuery(entry, choice);
438
533
  },
439
534
  }),
440
535
  () => {
441
536
  if (!selectionMade) resolveNetworkQuery(entry, 'deny');
442
537
  },
443
538
  );
539
+ api.ui.dialog.setSize('xlarge');
444
540
  }
445
541
 
446
542
  const unsubscribeAsked = api.event.on('permission.asked', (event) => {
@@ -629,12 +725,8 @@ const tui: TuiPlugin = async (api, options, meta) => {
629
725
  },
630
726
  ]);
631
727
 
632
- // Persistent status badge in the prompt area. It needs the host's Solid
633
- // runtime, imported defensively so a host that resolves plugin imports
634
- // differently still loads the plugin — the badge just stays absent there.
728
+ // Persistent status badge in the prompt area.
635
729
  try {
636
- const { jsx } = await import('@opentui/solid/jsx-runtime');
637
- const { createSignal } = await import('solid-js');
638
730
  const [sandboxRevision, setSandboxRevision] = createSignal(0);
639
731
  const statusBadge = (ctx: TuiSlotContext) => {
640
732
  sandboxRevision();