opencode-landstrip 0.16.11 → 0.16.12
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/package.json +1 -1
- package/tui.ts +72 -13
package/package.json
CHANGED
package/tui.ts
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
4
|
import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
|
|
5
|
+
import { realpathSync } from 'node:fs';
|
|
5
6
|
import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
6
9
|
|
|
7
10
|
import {
|
|
8
11
|
getConfigPaths,
|
|
@@ -64,6 +67,54 @@ function permissionDetail(permission: PendingPermission): string {
|
|
|
64
67
|
return resource && !label.includes(resource) ? `${label}: ${resource}` : label;
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
// Breadth-first filesystem approval: a held read/write under a directory tree
|
|
71
|
+
// is approved for the broadest reasonable ancestor (e.g. `~/.cargo`, not each
|
|
72
|
+
// subcrate file), so a single scan does not spawn one dialog per file. The
|
|
73
|
+
// session set stores directory prefixes and is matched with separator-safe
|
|
74
|
+
// prefix logic so a sibling file under an approved scope is auto-allowed.
|
|
75
|
+
function pathUnderDirectory(filePath: string, dir: string): boolean {
|
|
76
|
+
if (filePath === dir) return true;
|
|
77
|
+
const sep = dir.endsWith('/') ? '' : '/';
|
|
78
|
+
return filePath.startsWith(dir + sep);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sessionAllows(prefixes: Set<string>, filePath: string): boolean {
|
|
82
|
+
for (const prefix of prefixes) {
|
|
83
|
+
if (pathUnderDirectory(filePath, prefix)) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The broadest ancestor worth approving in one click: the immediate child of
|
|
89
|
+
// `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
|
|
90
|
+
// for paths under it, otherwise the containing directory. When the file sits
|
|
91
|
+
// directly on a boundary (so the only ancestor is `$HOME` itself, which would
|
|
92
|
+
// over-broaden), fall back to the exact file so nothing widens silently.
|
|
93
|
+
function sessionScopeFor(filePath: string, baseDirectory: string): string {
|
|
94
|
+
const dir = dirname(filePath);
|
|
95
|
+
const home = homedir();
|
|
96
|
+
const boundaries = new Set<string>();
|
|
97
|
+
if (home) boundaries.add(home);
|
|
98
|
+
try {
|
|
99
|
+
const realHome = realpathSync.native(home);
|
|
100
|
+
if (realHome) boundaries.add(realHome);
|
|
101
|
+
} catch {
|
|
102
|
+
// $HOME not resolvable — fall back to the raw value only.
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const boundary of boundaries) {
|
|
106
|
+
if (pathUnderDirectory(dir, boundary)) {
|
|
107
|
+
const rest = dir.slice(boundary.length).replace(/^\/+/, '');
|
|
108
|
+
const first = rest.split('/')[0];
|
|
109
|
+
if (!first) return filePath;
|
|
110
|
+
return boundary.endsWith('/') ? boundary + first : `${boundary}/${first}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (pathUnderDirectory(dir, baseDirectory)) return baseDirectory;
|
|
115
|
+
return dir;
|
|
116
|
+
}
|
|
117
|
+
|
|
67
118
|
const tui: TuiPlugin = async (api, options, meta) => {
|
|
68
119
|
const optionOverrides = normalizeOptions(options);
|
|
69
120
|
|
|
@@ -237,19 +288,23 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
237
288
|
if (resolved.has(entry.id)) return;
|
|
238
289
|
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
239
290
|
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
291
|
+
const directory = api.state.path.directory || process.cwd();
|
|
292
|
+
const scope = sessionScopeFor(entry.path, directory);
|
|
293
|
+
const sessionPaths =
|
|
294
|
+
entry.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
|
|
240
295
|
|
|
241
296
|
try {
|
|
242
297
|
if (action === 'allow') {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
298
|
+
// Breadth-first: seed the session set with the broadest reasonable
|
|
299
|
+
// ancestor so the still-running command stops prompting for sibling
|
|
300
|
+
// files under the same tree. 'once' intentionally stays exact-path.
|
|
301
|
+
if (choice !== 'once') sessionPaths.add(scope);
|
|
302
|
+
|
|
303
|
+
if (choice === 'project' || choice === 'global') {
|
|
249
304
|
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
250
305
|
const update = updateForPermission({
|
|
251
306
|
permission: entry.operation,
|
|
252
|
-
metadata: { filepath:
|
|
307
|
+
metadata: { filepath: scope },
|
|
253
308
|
});
|
|
254
309
|
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
255
310
|
}
|
|
@@ -259,7 +314,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
259
314
|
api.ui.toast({
|
|
260
315
|
title: 'Sandbox',
|
|
261
316
|
message:
|
|
262
|
-
action === 'deny'
|
|
317
|
+
action === 'deny'
|
|
318
|
+
? `${verb} denied: ${entry.path}`
|
|
319
|
+
: `${verb} allowed (${choice}) under ${scope}`,
|
|
263
320
|
variant: action === 'deny' ? 'warning' : 'success',
|
|
264
321
|
});
|
|
265
322
|
} catch {
|
|
@@ -276,6 +333,8 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
276
333
|
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
277
334
|
const noun = entry.operation;
|
|
278
335
|
const listName = entry.operation === 'read' ? 'allowRead' : 'allowWrite';
|
|
336
|
+
const directory = api.state.path.directory || process.cwd();
|
|
337
|
+
const scope = sessionScopeFor(entry.path, directory);
|
|
279
338
|
|
|
280
339
|
void api.attention.notify({
|
|
281
340
|
title: `Sandbox ${noun} blocked`,
|
|
@@ -299,25 +358,25 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
299
358
|
title: 'Allow once',
|
|
300
359
|
value: 'once',
|
|
301
360
|
category: `This ${noun}`,
|
|
302
|
-
description: `Permit this ${noun}
|
|
361
|
+
description: `Permit only this ${noun}`,
|
|
303
362
|
},
|
|
304
363
|
{
|
|
305
364
|
title: 'Allow for session',
|
|
306
365
|
value: 'session',
|
|
307
366
|
category: `This ${noun}`,
|
|
308
|
-
description: `Permit ${noun}s
|
|
367
|
+
description: `Permit ${noun}s under ${scope} for this session`,
|
|
309
368
|
},
|
|
310
369
|
{
|
|
311
370
|
title: 'Allow for project',
|
|
312
371
|
value: 'project',
|
|
313
372
|
category: 'Persist to sandbox.json',
|
|
314
|
-
description: `Add to .opencode/sandbox.json ${listName}
|
|
373
|
+
description: `Add ${scope} to .opencode/sandbox.json ${listName}`,
|
|
315
374
|
},
|
|
316
375
|
{
|
|
317
376
|
title: 'Allow globally',
|
|
318
377
|
value: 'global',
|
|
319
378
|
category: 'Persist to sandbox.json',
|
|
320
|
-
description: `Add to ~/.config/opencode/sandbox.json ${listName}
|
|
379
|
+
description: `Add ${scope} to ~/.config/opencode/sandbox.json ${listName}`,
|
|
321
380
|
},
|
|
322
381
|
{
|
|
323
382
|
title: 'Deny',
|
|
@@ -383,7 +442,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
383
442
|
|
|
384
443
|
const sessionPaths =
|
|
385
444
|
trap.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
|
|
386
|
-
if (sessionPaths
|
|
445
|
+
if (sessionAllows(sessionPaths, trap.path)) {
|
|
387
446
|
respondFsQuery(socket, trap.queryId, 'allow');
|
|
388
447
|
continue;
|
|
389
448
|
}
|