opencode-landstrip 0.17.43 → 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.
- package/README.md +7 -4
- package/index.ts +13 -2
- package/package.json +2 -2
- 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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
or
|
|
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/index.ts
CHANGED
|
@@ -838,7 +838,7 @@ function buildWrappedCommand(
|
|
|
838
838
|
command: string,
|
|
839
839
|
trapPort: number | null,
|
|
840
840
|
): string {
|
|
841
|
-
const baseArgs = ['-p', policyPath, ...shellArgs(shell, command)];
|
|
841
|
+
const baseArgs = ['run', '-p', policyPath, '--', ...shellArgs(shell, command)];
|
|
842
842
|
const plain = [landstripBinaryPath(), ...baseArgs].map(shellQuote).join(' ');
|
|
843
843
|
if (trapPort === null) return plain;
|
|
844
844
|
|
|
@@ -851,7 +851,18 @@ function buildWrappedCommand(
|
|
|
851
851
|
// and fall back to an explicit bash invocation that always speaks /dev/tcp.
|
|
852
852
|
// If both fail (no bash, dead port, set -e in the outer shell) landstrip runs
|
|
853
853
|
// without --trap-fd so the toast-notify path still works.
|
|
854
|
-
const trapped = [
|
|
854
|
+
const trapped = [
|
|
855
|
+
landstripBinaryPath(),
|
|
856
|
+
'run',
|
|
857
|
+
'--trap-fd',
|
|
858
|
+
'3',
|
|
859
|
+
'-p',
|
|
860
|
+
policyPath,
|
|
861
|
+
'--',
|
|
862
|
+
...shellArgs(shell, command),
|
|
863
|
+
]
|
|
864
|
+
.map(shellQuote)
|
|
865
|
+
.join(' ');
|
|
855
866
|
const bashTrap = `bash -c ${shellQuote(`exec 3<>/dev/tcp/127.0.0.1/${trapPort} 2>/dev/null && exec "$@"`)} bash ${trapped}`;
|
|
856
867
|
return `{ exec 3<>/dev/tcp/127.0.0.1/${trapPort} ; } 2>/dev/null && ${trapped} || ${bashTrap} 2>/dev/null || ${plain}`;
|
|
857
868
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
209
|
-
title: '
|
|
210
|
-
|
|
367
|
+
renderPermissionPrompt<PermissionChoice>({
|
|
368
|
+
title: 'Permission required',
|
|
369
|
+
icon: '⚙',
|
|
370
|
+
detail: permissionDetail(permission),
|
|
211
371
|
options: [
|
|
212
|
-
{
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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: (
|
|
244
|
-
void replyPermission(permission,
|
|
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 ${
|
|
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
|
-
|
|
330
|
-
title:
|
|
331
|
-
|
|
461
|
+
renderPermissionPrompt<QueryChoice>({
|
|
462
|
+
title: 'Sandbox access blocked',
|
|
463
|
+
icon: '→',
|
|
464
|
+
detail: `${verb}: ${entry.path}`,
|
|
332
465
|
options: [
|
|
333
|
-
{
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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: (
|
|
472
|
+
onSelect: (choice) => {
|
|
365
473
|
selectionMade = true;
|
|
366
|
-
resolveFsQuery(entry,
|
|
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
|
-
|
|
413
|
-
title: 'Sandbox
|
|
414
|
-
|
|
521
|
+
renderPermissionPrompt<NetworkQueryChoice>({
|
|
522
|
+
title: 'Sandbox access blocked',
|
|
523
|
+
icon: '%',
|
|
524
|
+
detail: `${entry.operation}: ${entry.target}`,
|
|
415
525
|
options: [
|
|
416
|
-
{
|
|
417
|
-
|
|
418
|
-
|
|
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: (
|
|
530
|
+
onSelect: (choice) => {
|
|
436
531
|
selectionMade = true;
|
|
437
|
-
resolveNetworkQuery(entry,
|
|
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.
|
|
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();
|