opencode-landstrip 0.18.2 → 0.18.4
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 +8 -7
- package/index.ts +12 -16
- package/package.json +2 -2
- package/tui.ts +135 -224
package/README.md
CHANGED
|
@@ -60,13 +60,14 @@ sets it, otherwise to the global config.
|
|
|
60
60
|
|
|
61
61
|
## Behavior
|
|
62
62
|
|
|
63
|
-
When OpenCode asks whether the agent may invoke a sandboxed tool,
|
|
64
|
-
|
|
65
|
-
filesystem or network boundary, the plugin
|
|
66
|
-
|
|
67
|
-
permission
|
|
68
|
-
The user can allow once, allow for the
|
|
69
|
-
project or globally, or
|
|
63
|
+
When OpenCode asks whether the agent may invoke a sandboxed tool, OpenCode keeps
|
|
64
|
+
showing its native agent-permission prompt. If a running command instead reaches
|
|
65
|
+
a filesystem or network boundary, the plugin replaces the composer with an inline
|
|
66
|
+
Landstrip prompt that uses the same layout, colors, controls, and path formatting.
|
|
67
|
+
OpenCode's native permission and question prompts take priority; the Landstrip
|
|
68
|
+
prompt returns after they are resolved. The user can allow once, allow for the
|
|
69
|
+
session, persist a filesystem approval for the project or globally, or deny the
|
|
70
|
+
request.
|
|
70
71
|
|
|
71
72
|
Project approvals are written to `.opencode/sandbox.json`; global approvals go
|
|
72
73
|
to `~/.config/opencode/sandbox.json`. A newly created global file starts with the
|
package/index.ts
CHANGED
|
@@ -789,7 +789,8 @@ function startTrapServer(
|
|
|
789
789
|
if (allowed) {
|
|
790
790
|
trapSocket.write(controlResponseLine(queryId, 'allow'));
|
|
791
791
|
} else {
|
|
792
|
-
//
|
|
792
|
+
// Remember the scope for later commands, but deny this syscall so
|
|
793
|
+
// a command that may already have side effects is never retried.
|
|
793
794
|
const scope = sessionScopeFor(path, baseDirectory);
|
|
794
795
|
if (operation === 'read') sessionAllowedReadPaths.add(scope);
|
|
795
796
|
else sessionAllowedWritePaths.add(scope);
|
|
@@ -842,15 +843,10 @@ function buildWrappedCommand(
|
|
|
842
843
|
const plain = [landstripBinaryPath(), ...baseArgs].map(shellQuote).join(' ');
|
|
843
844
|
if (trapPort === null) return plain;
|
|
844
845
|
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
// /dev/tcp is a bash/ksh built-in — it does not work in zsh, dash, or fish.
|
|
850
|
-
// We try the native redirect first (fast path when the host shell supports it)
|
|
851
|
-
// and fall back to an explicit bash invocation that always speaks /dev/tcp.
|
|
852
|
-
// If both fail (no bash, dead port, set -e in the outer shell) landstrip runs
|
|
853
|
-
// without --trap-fd so the toast-notify path still works.
|
|
846
|
+
// Open fd 3 before landstrip starts the command. The wrapper must never use
|
|
847
|
+
// the command's exit status to select a fallback: by then the command may
|
|
848
|
+
// already have changed the workspace. A failed socket setup therefore stops
|
|
849
|
+
// without invoking landstrip, while every started command runs exactly once.
|
|
854
850
|
const trapped = [
|
|
855
851
|
landstripBinaryPath(),
|
|
856
852
|
'run',
|
|
@@ -863,14 +859,14 @@ function buildWrappedCommand(
|
|
|
863
859
|
]
|
|
864
860
|
.map(shellQuote)
|
|
865
861
|
.join(' ');
|
|
866
|
-
const
|
|
867
|
-
return `
|
|
862
|
+
const openTrap = `exec 3<>/dev/tcp/127.0.0.1/${trapPort} && exec "$@"`;
|
|
863
|
+
return `bash -c ${shellQuote(openTrap)} bash ${trapped}`;
|
|
868
864
|
}
|
|
869
865
|
|
|
870
866
|
function isGeneratedWrappedCommand(command: string): boolean {
|
|
871
867
|
return (
|
|
872
|
-
// `.includes` rather than `.startsWith`: the query-response form prefixes
|
|
873
|
-
//
|
|
868
|
+
// `.includes` rather than `.startsWith`: the query-response form prefixes
|
|
869
|
+
// the landstrip invocation with a small bash fd-setup wrapper.
|
|
874
870
|
command.includes(`${shellQuote(landstripBinaryPath())} `) &&
|
|
875
871
|
command.includes(` ${shellQuote('-p')} `) &&
|
|
876
872
|
command.includes('opencode-landstrip-')
|
|
@@ -921,8 +917,8 @@ function extractOriginalCommand(wrappedCommand: string): string | null {
|
|
|
921
917
|
const pIdx = args.indexOf('-p');
|
|
922
918
|
const flagIdx = args.findIndex((arg, i) => i > pIdx && (arg === '-lc' || arg === '-c'));
|
|
923
919
|
if (flagIdx === -1) return null;
|
|
924
|
-
//
|
|
925
|
-
//
|
|
920
|
+
// Old query-response wrappers appended `|| <fallback>`; stop there so
|
|
921
|
+
// recovering an expired wrapper never folds a fallback into the command.
|
|
926
922
|
const end = args.indexOf('||', flagIdx + 1);
|
|
927
923
|
return (end === -1 ? args.slice(flagIdx + 1) : args.slice(flagIdx + 1, end)).join(' ');
|
|
928
924
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.4",
|
|
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.
|
|
51
|
+
"@landstrip/landstrip": "^0.18.4",
|
|
52
52
|
"@opentui/solid": ">=0.3.4",
|
|
53
53
|
"solid-js": "1.9.12"
|
|
54
54
|
},
|
package/tui.ts
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
4
|
import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
|
|
5
|
-
|
|
6
|
-
import
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
TuiHostSlotMap,
|
|
10
|
+
TuiPlugin,
|
|
11
|
+
TuiSlotContext,
|
|
12
|
+
TuiSlotPlugin,
|
|
13
|
+
} from '@opencode-ai/plugin/tui';
|
|
14
|
+
import { RGBA } from '@opentui/core';
|
|
7
15
|
import { useTerminalDimensions } from '@opentui/solid';
|
|
8
|
-
import { jsx, jsxs } from '@opentui/solid/jsx-runtime';
|
|
16
|
+
import { Fragment, jsx, jsxs } from '@opentui/solid/jsx-runtime';
|
|
9
17
|
import { createSignal, onCleanup } from 'solid-js';
|
|
10
18
|
|
|
11
19
|
import {
|
|
@@ -14,8 +22,6 @@ import {
|
|
|
14
22
|
loadConfig,
|
|
15
23
|
normalizeOptions,
|
|
16
24
|
parseLandstripTraps,
|
|
17
|
-
permissionLabel,
|
|
18
|
-
permissionResource,
|
|
19
25
|
removeDiscoveryFile,
|
|
20
26
|
sessionAllows,
|
|
21
27
|
sandboxSummary,
|
|
@@ -26,20 +32,6 @@ import {
|
|
|
26
32
|
writeDiscoveryPort,
|
|
27
33
|
} from './shared.js';
|
|
28
34
|
|
|
29
|
-
// The shape shared by the `permission.asked` event payload and the entries
|
|
30
|
-
// returned from `api.state.session.permission()`. Both carry `permission`
|
|
31
|
-
// (the kind), `patterns`, and `tool.callID`; neither carries a `title`.
|
|
32
|
-
interface PendingPermission {
|
|
33
|
-
id: string;
|
|
34
|
-
sessionID: string;
|
|
35
|
-
permission: string;
|
|
36
|
-
patterns: string[];
|
|
37
|
-
metadata: Record<string, unknown>;
|
|
38
|
-
tool?: { callID: string };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
|
|
42
|
-
|
|
43
35
|
type QueryChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
|
|
44
36
|
|
|
45
37
|
// No project/global option: no landstrip policy field expresses "allow this
|
|
@@ -52,11 +44,11 @@ interface PromptOption<Value extends string> {
|
|
|
52
44
|
}
|
|
53
45
|
|
|
54
46
|
interface PermissionPromptProps<Value extends string> {
|
|
55
|
-
title: string;
|
|
56
47
|
icon: string;
|
|
57
|
-
|
|
48
|
+
title: string;
|
|
58
49
|
options: readonly PromptOption<Value>[];
|
|
59
50
|
onSelect: (value: Value) => void;
|
|
51
|
+
onCancel: () => void;
|
|
60
52
|
}
|
|
61
53
|
|
|
62
54
|
const promptBorderChars = {
|
|
@@ -74,8 +66,8 @@ const promptBorderChars = {
|
|
|
74
66
|
};
|
|
75
67
|
|
|
76
68
|
// A landstrip filesystem query (read or write) held pending over the fd-3
|
|
77
|
-
// socket.
|
|
78
|
-
//
|
|
69
|
+
// socket. Filesystem and network queries share one queue so only one toolchain
|
|
70
|
+
// prompt is active at a time.
|
|
79
71
|
interface FsQueryEntry {
|
|
80
72
|
kind: 'fs-query';
|
|
81
73
|
id: string;
|
|
@@ -98,28 +90,38 @@ interface NetworkQueryEntry {
|
|
|
98
90
|
target: string;
|
|
99
91
|
}
|
|
100
92
|
|
|
101
|
-
|
|
102
|
-
kind: 'permission';
|
|
103
|
-
id: string;
|
|
104
|
-
permission: PendingPermission;
|
|
105
|
-
}
|
|
93
|
+
type QueueEntry = FsQueryEntry | NetworkQueryEntry;
|
|
106
94
|
|
|
107
|
-
|
|
95
|
+
function formatPath(input: string, base: string): string {
|
|
96
|
+
const absolute = path.isAbsolute(input) ? input : path.resolve(base, input);
|
|
97
|
+
const relative = path.relative(base, absolute);
|
|
108
98
|
|
|
109
|
-
|
|
110
|
-
|
|
99
|
+
if (!relative) return '.';
|
|
100
|
+
if (relative !== '..' && !relative.startsWith(`..${path.sep}`)) return relative;
|
|
101
|
+
|
|
102
|
+
const home = homedir();
|
|
103
|
+
if (absolute === home) return '~';
|
|
104
|
+
if (absolute.startsWith(`${home}${path.sep}`)) return `~${absolute.slice(home.length)}`;
|
|
105
|
+
return absolute;
|
|
111
106
|
}
|
|
112
107
|
|
|
113
|
-
function
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
108
|
+
function selectedForeground(theme: TuiSlotContext['theme']['current']): RGBA {
|
|
109
|
+
const resolved = theme as typeof theme & { _hasSelectedListItemText?: boolean };
|
|
110
|
+
if (resolved._hasSelectedListItemText) return theme.selectedListItemText;
|
|
111
|
+
|
|
112
|
+
if (theme.background.a === 0) {
|
|
113
|
+
const { r, g, b } = theme.warning;
|
|
114
|
+
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
115
|
+
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
|
|
116
|
+
}
|
|
117
|
+
return theme.background;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
const tui: TuiPlugin = async (api, options, meta) => {
|
|
120
121
|
const optionOverrides = normalizeOptions(options);
|
|
121
122
|
|
|
122
|
-
function
|
|
123
|
+
function LandstripPermissionPrompt(rawProps: Record<string, unknown>) {
|
|
124
|
+
const props = rawProps as unknown as PermissionPromptProps<string>;
|
|
123
125
|
const theme = api.theme.current;
|
|
124
126
|
const dimensions = useTerminalDimensions();
|
|
125
127
|
const [selected, setSelected] = createSignal(0);
|
|
@@ -141,6 +143,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
141
143
|
{ key: 'right', cmd: () => move(1) },
|
|
142
144
|
{ key: 'l', cmd: () => move(1) },
|
|
143
145
|
{ key: 'return', cmd: submit },
|
|
146
|
+
{ key: 'escape', cmd: props.onCancel },
|
|
144
147
|
],
|
|
145
148
|
});
|
|
146
149
|
onCleanup(unregister);
|
|
@@ -149,6 +152,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
149
152
|
jsx('box', {
|
|
150
153
|
paddingLeft: 1,
|
|
151
154
|
paddingRight: 1,
|
|
155
|
+
flexShrink: 0,
|
|
152
156
|
get backgroundColor() {
|
|
153
157
|
return selected() === index ? theme.warning : theme.backgroundMenu;
|
|
154
158
|
},
|
|
@@ -159,7 +163,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
159
163
|
},
|
|
160
164
|
children: jsx('text', {
|
|
161
165
|
get fg() {
|
|
162
|
-
return selected() === index ? theme
|
|
166
|
+
return selected() === index ? selectedForeground(theme) : theme.textMuted;
|
|
163
167
|
},
|
|
164
168
|
children: option.label,
|
|
165
169
|
}),
|
|
@@ -167,6 +171,12 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
167
171
|
);
|
|
168
172
|
|
|
169
173
|
return jsxs('box', {
|
|
174
|
+
top: 0,
|
|
175
|
+
maxHeight: 15,
|
|
176
|
+
bottom: 0,
|
|
177
|
+
left: 0,
|
|
178
|
+
right: 0,
|
|
179
|
+
position: 'relative',
|
|
170
180
|
border: ['left'],
|
|
171
181
|
borderColor: theme.warning,
|
|
172
182
|
customBorderChars: promptBorderChars,
|
|
@@ -174,33 +184,44 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
174
184
|
children: [
|
|
175
185
|
jsxs('box', {
|
|
176
186
|
gap: 1,
|
|
177
|
-
paddingLeft:
|
|
187
|
+
paddingLeft: 1,
|
|
178
188
|
paddingRight: 3,
|
|
179
189
|
paddingTop: 1,
|
|
180
190
|
paddingBottom: 1,
|
|
191
|
+
flexGrow: 1,
|
|
181
192
|
children: [
|
|
182
193
|
jsxs('box', {
|
|
183
|
-
flexDirection: '
|
|
184
|
-
gap:
|
|
194
|
+
flexDirection: 'column',
|
|
195
|
+
gap: 0,
|
|
196
|
+
paddingLeft: 1,
|
|
197
|
+
flexShrink: 0,
|
|
185
198
|
children: [
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
jsxs('box', {
|
|
200
|
+
flexDirection: 'row',
|
|
201
|
+
gap: 1,
|
|
202
|
+
flexShrink: 0,
|
|
203
|
+
children: [
|
|
204
|
+
jsx('text', { fg: theme.warning, children: '△' }),
|
|
205
|
+
jsx('text', { fg: theme.text, children: 'Permission required' }),
|
|
206
|
+
],
|
|
207
|
+
}),
|
|
208
|
+
jsxs('box', {
|
|
209
|
+
flexDirection: 'row',
|
|
210
|
+
gap: 1,
|
|
211
|
+
paddingLeft: 2,
|
|
212
|
+
flexShrink: 0,
|
|
213
|
+
children: [
|
|
214
|
+
jsx('text', { fg: theme.textMuted, flexShrink: 0, children: props.icon }),
|
|
215
|
+
jsx('text', { fg: theme.text, wrapMode: 'word', children: props.title }),
|
|
216
|
+
],
|
|
217
|
+
}),
|
|
197
218
|
],
|
|
198
219
|
}),
|
|
199
220
|
],
|
|
200
221
|
}),
|
|
201
222
|
jsxs('box', {
|
|
202
223
|
get flexDirection() {
|
|
203
|
-
return dimensions().width <
|
|
224
|
+
return dimensions().width < 80 ? 'column' : 'row';
|
|
204
225
|
},
|
|
205
226
|
flexShrink: 0,
|
|
206
227
|
gap: 1,
|
|
@@ -210,20 +231,22 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
210
231
|
paddingBottom: 1,
|
|
211
232
|
backgroundColor: theme.backgroundElement,
|
|
212
233
|
get justifyContent() {
|
|
213
|
-
return dimensions().width <
|
|
234
|
+
return dimensions().width < 80 ? 'flex-start' : 'space-between';
|
|
214
235
|
},
|
|
215
236
|
get alignItems() {
|
|
216
|
-
return dimensions().width <
|
|
237
|
+
return dimensions().width < 80 ? 'flex-start' : 'center';
|
|
217
238
|
},
|
|
218
239
|
children: [
|
|
219
240
|
jsx('box', {
|
|
220
241
|
flexDirection: 'row',
|
|
221
242
|
gap: 1,
|
|
243
|
+
flexShrink: 0,
|
|
222
244
|
children: optionButtons,
|
|
223
245
|
}),
|
|
224
246
|
jsxs('box', {
|
|
225
247
|
flexDirection: 'row',
|
|
226
248
|
gap: 2,
|
|
249
|
+
flexShrink: 0,
|
|
227
250
|
children: [
|
|
228
251
|
jsxs('text', {
|
|
229
252
|
fg: theme.text,
|
|
@@ -247,13 +270,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
247
270
|
});
|
|
248
271
|
}
|
|
249
272
|
|
|
250
|
-
// Permission requests can arrive twice (the live event and a reconnect replay
|
|
251
|
-
// of `api.state`), so `resolved` tracks ids we have already answered and
|
|
252
|
-
// `activeId` guards against stacking a second sandbox dialog on the first.
|
|
253
|
-
// Write queries share the same queue so a held-write prompt never stacks on a
|
|
254
|
-
// permission prompt.
|
|
255
273
|
const resolved = new Set<string>();
|
|
256
274
|
const queue: QueueEntry[] = [];
|
|
275
|
+
const [activeEntry, setActiveEntry] = createSignal<QueueEntry>();
|
|
257
276
|
let activeId: string | undefined;
|
|
258
277
|
let refreshSandboxStatus: (() => void) | undefined;
|
|
259
278
|
|
|
@@ -277,8 +296,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
277
296
|
let next = queue.shift();
|
|
278
297
|
while (next && resolved.has(next.id)) next = queue.shift();
|
|
279
298
|
if (!next) return;
|
|
280
|
-
if (next.kind === '
|
|
281
|
-
else if (next.kind === 'fs-query') showFsQuery(next);
|
|
299
|
+
if (next.kind === 'fs-query') showFsQuery(next);
|
|
282
300
|
else showNetworkQuery(next);
|
|
283
301
|
}
|
|
284
302
|
|
|
@@ -290,106 +308,68 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
290
308
|
pump();
|
|
291
309
|
}
|
|
292
310
|
|
|
293
|
-
function enqueue(permission: PendingPermission): void {
|
|
294
|
-
if (!permission.id) return;
|
|
295
|
-
enqueueEntry({ kind: 'permission', id: permission.id, permission });
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Safety net for missed/late events and reconnects: fold whatever the host
|
|
299
|
-
// still considers pending for this session back into the queue.
|
|
300
|
-
function reconcile(sessionID: string): void {
|
|
301
|
-
for (const pending of api.state.session.permission(sessionID)) {
|
|
302
|
-
enqueue(pending as PendingPermission);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
311
|
function finishActive(id: string): void {
|
|
307
312
|
resolved.add(id);
|
|
308
313
|
if (activeId === id) {
|
|
309
314
|
activeId = undefined;
|
|
310
|
-
|
|
315
|
+
setActiveEntry(undefined);
|
|
311
316
|
}
|
|
312
|
-
// Defer: `clear()` above tears the dialog down by calling its `onClose`,
|
|
313
|
-
// and the host pops the stack asynchronously. Opening the next dialog
|
|
314
|
-
// synchronously here would race that teardown and get wiped.
|
|
315
317
|
queueMicrotask(pump);
|
|
316
318
|
}
|
|
317
319
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
icon: '⚙',
|
|
370
|
-
detail: permissionDetail(permission),
|
|
371
|
-
options: [
|
|
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' },
|
|
377
|
-
],
|
|
378
|
-
onSelect: (choice) => {
|
|
379
|
-
void replyPermission(permission, choice);
|
|
380
|
-
},
|
|
381
|
-
}),
|
|
382
|
-
() => {
|
|
383
|
-
// Dialog dismissed (esc) without a choice: drop our hold so the next
|
|
384
|
-
// pending permission can surface, but leave it unresolved upstream.
|
|
385
|
-
// The host pops the dialog itself; calling `clear()` here would re-enter
|
|
386
|
-
// this `onClose` (clear() invokes every entry's onClose) and loop until
|
|
387
|
-
// the stack overflows. Defer `pump()` so the pop settles first.
|
|
388
|
-
if (activeId === permission.id) activeId = undefined;
|
|
389
|
-
queueMicrotask(pump);
|
|
320
|
+
function renderSessionPrompt(props: TuiHostSlotMap['session_prompt']) {
|
|
321
|
+
return jsx(Fragment, {
|
|
322
|
+
get children() {
|
|
323
|
+
const entry = activeEntry();
|
|
324
|
+
const directory =
|
|
325
|
+
api.state.session.get(props.session_id)?.directory ||
|
|
326
|
+
api.state.path.directory ||
|
|
327
|
+
process.cwd();
|
|
328
|
+
if (entry?.kind === 'fs-query') {
|
|
329
|
+
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
330
|
+
return jsx(LandstripPermissionPrompt, {
|
|
331
|
+
icon: '→',
|
|
332
|
+
title: `${verb} ${formatPath(entry.path, directory)}`,
|
|
333
|
+
options: [
|
|
334
|
+
{ label: 'Allow once', value: 'once' },
|
|
335
|
+
{ label: 'Allow for session', value: 'session' },
|
|
336
|
+
{ label: 'Allow for project', value: 'project' },
|
|
337
|
+
{ label: 'Allow globally', value: 'global' },
|
|
338
|
+
{ label: 'Deny', value: 'deny' },
|
|
339
|
+
],
|
|
340
|
+
onSelect: (choice: QueryChoice) => resolveFsQuery(entry, choice),
|
|
341
|
+
onCancel: () => resolveFsQuery(entry, 'deny'),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (entry?.kind === 'net-query') {
|
|
345
|
+
const verb = entry.operation
|
|
346
|
+
? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
|
|
347
|
+
: 'Network';
|
|
348
|
+
return jsx(LandstripPermissionPrompt, {
|
|
349
|
+
icon: '%',
|
|
350
|
+
title: `${verb} ${entry.target}`,
|
|
351
|
+
options: [
|
|
352
|
+
{ label: 'Allow once', value: 'once' },
|
|
353
|
+
{ label: 'Allow for session', value: 'session' },
|
|
354
|
+
{ label: 'Deny', value: 'deny' },
|
|
355
|
+
],
|
|
356
|
+
onSelect: (choice: NetworkQueryChoice) => resolveNetworkQuery(entry, choice),
|
|
357
|
+
onCancel: () => resolveNetworkQuery(entry, 'deny'),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return api.ui.Prompt({
|
|
361
|
+
sessionID: props.session_id,
|
|
362
|
+
...(props.visible === undefined ? {} : { visible: props.visible }),
|
|
363
|
+
...(props.disabled === undefined ? {} : { disabled: props.disabled }),
|
|
364
|
+
...(props.on_submit === undefined ? {} : { onSubmit: props.on_submit }),
|
|
365
|
+
...(props.ref === undefined ? {} : { ref: props.ref }),
|
|
366
|
+
right: api.ui.Slot({
|
|
367
|
+
name: 'session_prompt_right',
|
|
368
|
+
session_id: props.session_id,
|
|
369
|
+
}),
|
|
370
|
+
});
|
|
390
371
|
},
|
|
391
|
-
);
|
|
392
|
-
api.ui.dialog.setSize('xlarge');
|
|
372
|
+
});
|
|
393
373
|
}
|
|
394
374
|
|
|
395
375
|
function respondQuery(socket: NetSocket, queryId: string, action: 'allow' | 'deny'): void {
|
|
@@ -442,7 +422,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
442
422
|
|
|
443
423
|
function showFsQuery(entry: FsQueryEntry): void {
|
|
444
424
|
activeId = entry.id;
|
|
445
|
-
|
|
425
|
+
setActiveEntry(entry);
|
|
446
426
|
|
|
447
427
|
void api.attention.notify({
|
|
448
428
|
title: `Sandbox ${entry.operation} blocked`,
|
|
@@ -450,37 +430,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
450
430
|
sound: { name: 'permission' },
|
|
451
431
|
notification: true,
|
|
452
432
|
});
|
|
453
|
-
|
|
454
|
-
// A selection pops the dialog (firing `onClose`); track it so the esc-path
|
|
455
|
-
// deny does not override the user's choice. A held syscall must always be
|
|
456
|
-
// answered, so esc/dismiss denies rather than leaving it unresolved.
|
|
457
|
-
let selectionMade = false;
|
|
458
|
-
|
|
459
|
-
api.ui.dialog.replace(
|
|
460
|
-
() =>
|
|
461
|
-
renderPermissionPrompt<QueryChoice>({
|
|
462
|
-
title: 'Sandbox access blocked',
|
|
463
|
-
icon: '→',
|
|
464
|
-
detail: `${verb}: ${entry.path}`,
|
|
465
|
-
options: [
|
|
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' },
|
|
471
|
-
],
|
|
472
|
-
onSelect: (choice) => {
|
|
473
|
-
selectionMade = true;
|
|
474
|
-
resolveFsQuery(entry, choice);
|
|
475
|
-
},
|
|
476
|
-
}),
|
|
477
|
-
() => {
|
|
478
|
-
if (!selectionMade) resolveFsQuery(entry, 'deny');
|
|
479
|
-
},
|
|
480
|
-
);
|
|
481
|
-
api.ui.dialog.setSize('xlarge');
|
|
482
433
|
}
|
|
483
|
-
|
|
484
434
|
function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
|
|
485
435
|
if (resolved.has(entry.id)) return;
|
|
486
436
|
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
@@ -505,6 +455,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
505
455
|
|
|
506
456
|
function showNetworkQuery(entry: NetworkQueryEntry): void {
|
|
507
457
|
activeId = entry.id;
|
|
458
|
+
setActiveEntry(entry);
|
|
508
459
|
|
|
509
460
|
void api.attention.notify({
|
|
510
461
|
title: `Sandbox ${entry.operation} blocked`,
|
|
@@ -512,46 +463,8 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
512
463
|
sound: { name: 'permission' },
|
|
513
464
|
notification: true,
|
|
514
465
|
});
|
|
515
|
-
|
|
516
|
-
// Same esc-denies-a-held-syscall reasoning as showFsQuery.
|
|
517
|
-
let selectionMade = false;
|
|
518
|
-
|
|
519
|
-
api.ui.dialog.replace(
|
|
520
|
-
() =>
|
|
521
|
-
renderPermissionPrompt<NetworkQueryChoice>({
|
|
522
|
-
title: 'Sandbox access blocked',
|
|
523
|
-
icon: '%',
|
|
524
|
-
detail: `${entry.operation}: ${entry.target}`,
|
|
525
|
-
options: [
|
|
526
|
-
{ label: 'Allow once', value: 'once' },
|
|
527
|
-
{ label: 'Allow for session', value: 'session' },
|
|
528
|
-
{ label: 'Deny', value: 'deny' },
|
|
529
|
-
],
|
|
530
|
-
onSelect: (choice) => {
|
|
531
|
-
selectionMade = true;
|
|
532
|
-
resolveNetworkQuery(entry, choice);
|
|
533
|
-
},
|
|
534
|
-
}),
|
|
535
|
-
() => {
|
|
536
|
-
if (!selectionMade) resolveNetworkQuery(entry, 'deny');
|
|
537
|
-
},
|
|
538
|
-
);
|
|
539
|
-
api.ui.dialog.setSize('xlarge');
|
|
540
466
|
}
|
|
541
467
|
|
|
542
|
-
const unsubscribeAsked = api.event.on('permission.asked', (event) => {
|
|
543
|
-
const directory = api.state.path.directory;
|
|
544
|
-
if (!directory) return;
|
|
545
|
-
if (!loadConfig(directory, optionOverrides).enabled) return;
|
|
546
|
-
const pending = event.properties as PendingPermission;
|
|
547
|
-
enqueue(pending);
|
|
548
|
-
reconcile(pending.sessionID);
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
const unsubscribeReplied = api.event.on('permission.replied', (event) => {
|
|
552
|
-
finishActive(event.properties.requestID);
|
|
553
|
-
});
|
|
554
|
-
|
|
555
468
|
// Query-response socket server (Linux-only — landstrip's socket protocol lives
|
|
556
469
|
// in the seccomp broker). The server plugin connects each sandboxed run's
|
|
557
470
|
// fd 3 here via a /dev/tcp redirect and we answer held writes interactively.
|
|
@@ -746,6 +659,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
746
659
|
const statusSlot: TuiSlotPlugin = {
|
|
747
660
|
slots: {
|
|
748
661
|
home_prompt_right: (ctx) => statusBadge(ctx),
|
|
662
|
+
session_prompt: (_ctx, props) => renderSessionPrompt(props),
|
|
749
663
|
session_prompt_right: (ctx) => statusBadge(ctx),
|
|
750
664
|
},
|
|
751
665
|
};
|
|
@@ -769,9 +683,6 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
769
683
|
}
|
|
770
684
|
|
|
771
685
|
api.lifecycle.onDispose(() => {
|
|
772
|
-
unsubscribeAsked();
|
|
773
|
-
unsubscribeReplied();
|
|
774
|
-
|
|
775
686
|
// Deny any still-held queries so the sandboxed children don't hang, then
|
|
776
687
|
// tear down the socket server and drop the discovery file.
|
|
777
688
|
for (const entry of liveQueries) {
|