opencode-landstrip 0.18.2 → 0.18.3

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 (4) hide show
  1. package/README.md +8 -7
  2. package/index.ts +12 -16
  3. package/package.json +2 -2
  4. 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, 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.
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
- // Auto-grant via session scope and allow so the command proceeds.
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
- // Connect fd 3 to the TUI's query-response socket BEFORE landstrip applies the
846
- // sandbox, so a denied filesystem access can be approved live instead of
847
- // forcing a re-run.
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 bashTrap = `bash -c ${shellQuote(`exec 3<>/dev/tcp/127.0.0.1/${trapPort} 2>/dev/null && exec "$@"`)} bash ${trapped}`;
867
- return `{ exec 3<>/dev/tcp/127.0.0.1/${trapPort} ; } 2>/dev/null && ${trapped} || ${bashTrap} 2>/dev/null || ${plain}`;
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 a
873
- // `{ exec 3<>/dev/tcp/...; } && ` redirect before the landstrip invocation.
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
- // The query-response form appends `|| <plain invocation>`; stop at that
925
- // separator so the fallback branch is not folded into the recovered command.
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.2",
3
+ "version": "0.18.3",
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.2",
51
+ "@landstrip/landstrip": "^0.18.3",
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 type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
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
- detail: string;
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. It shares the dialog stack with permission prompts so the two never
78
- // overlap, hence the common `id`/`kind` shape.
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
- interface PermissionEntry {
102
- kind: 'permission';
103
- id: string;
104
- permission: PendingPermission;
105
- }
93
+ type QueueEntry = FsQueryEntry | NetworkQueryEntry;
106
94
 
107
- type QueueEntry = PermissionEntry | FsQueryEntry | NetworkQueryEntry;
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
- function asRecord(permission: PendingPermission): Record<string, unknown> {
110
- return permission as unknown as Record<string, unknown>;
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 permissionDetail(permission: PendingPermission): string {
114
- const label = permissionLabel(asRecord(permission));
115
- const resource = permissionResource(asRecord(permission));
116
- return resource && !label.includes(resource) ? `${label}: ${resource}` : label;
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 renderPermissionPrompt<Value extends string>(props: PermissionPromptProps<Value>) {
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.selectedListItemText : theme.textMuted;
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: 2,
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: 'row',
184
- gap: 1,
194
+ flexDirection: 'column',
195
+ gap: 0,
196
+ paddingLeft: 1,
197
+ flexShrink: 0,
185
198
  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 }),
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 < 100 ? 'column' : 'row';
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 < 100 ? 'flex-start' : 'space-between';
234
+ return dimensions().width < 80 ? 'flex-start' : 'space-between';
214
235
  },
215
236
  get alignItems() {
216
- return dimensions().width < 100 ? 'flex-start' : 'center';
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 === 'permission') showPermission(next.permission);
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
- api.ui.dialog.clear();
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
- async function replyPermission(
319
- permission: PendingPermission,
320
- choice: PermissionChoice,
321
- ): Promise<void> {
322
- const { id, sessionID } = permission;
323
- if (!id || !sessionID) return;
324
-
325
- const directory = api.state.path.directory || process.cwd();
326
- const { globalPath, projectPath } = getConfigPaths(directory);
327
-
328
- try {
329
- if (choice === 'project' || choice === 'global') {
330
- const update = updateForPermission(asRecord(permission));
331
- if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
332
- }
333
-
334
- await api.client.permission.reply({
335
- requestID: id,
336
- reply: choice === 'reject' ? 'reject' : choice === 'once' ? 'once' : 'always',
337
- });
338
-
339
- api.ui.toast({
340
- title: 'Sandbox',
341
- message: choice === 'reject' ? 'Permission rejected' : `Permission allowed for ${choice}`,
342
- variant: choice === 'reject' ? 'warning' : 'success',
343
- });
344
- } catch {
345
- api.ui.toast({
346
- title: 'Sandbox',
347
- message: 'Permission was already handled or could not be updated',
348
- variant: 'warning',
349
- });
350
- } finally {
351
- finishActive(id);
352
- }
353
- }
354
-
355
- function showPermission(permission: PendingPermission): void {
356
- activeId = permission.id;
357
-
358
- void api.attention.notify({
359
- title: 'Sandbox permission',
360
- message: permissionDetail(permission),
361
- sound: { name: 'permission' },
362
- notification: true,
363
- });
364
-
365
- api.ui.dialog.replace(
366
- () =>
367
- renderPermissionPrompt<PermissionChoice>({
368
- title: 'Permission required',
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
- const verb = entry.operation === 'read' ? 'Read' : 'Write';
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) {