opencode-landstrip 0.20.15 → 0.20.17
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 +2 -2
- package/tui.ts +74 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.17",
|
|
4
4
|
"description": "OS-level AI command 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-api": "^0.20.
|
|
51
|
+
"@landstrip/landstrip-api": "^0.20.17",
|
|
52
52
|
"@opentui/core": ">=0.5.12",
|
|
53
53
|
"@opentui/solid": ">=0.5.12",
|
|
54
54
|
"solid-js": "1.9.12"
|
package/tui.ts
CHANGED
|
@@ -16,11 +16,11 @@ import {
|
|
|
16
16
|
controlResponseLine,
|
|
17
17
|
evaluateReadAccess,
|
|
18
18
|
evaluateWriteAccess,
|
|
19
|
-
isPathReadAllowed,
|
|
20
|
-
isPathWriteAllowed,
|
|
21
19
|
isQueryTrap,
|
|
20
|
+
matchesPathPattern,
|
|
22
21
|
parseLandstripTraps,
|
|
23
|
-
|
|
22
|
+
readGrantScopesFor,
|
|
23
|
+
writeGrantScopesFor,
|
|
24
24
|
} from '@landstrip/landstrip-api/shared';
|
|
25
25
|
|
|
26
26
|
import {
|
|
@@ -42,10 +42,6 @@ import {
|
|
|
42
42
|
|
|
43
43
|
type QueryChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
|
|
44
44
|
|
|
45
|
-
// No project/global option: no landstrip policy field expresses "allow this
|
|
46
|
-
// address:port" the way allowRead/allowWrite express a path.
|
|
47
|
-
type NetworkQueryChoice = 'once' | 'session' | 'deny';
|
|
48
|
-
|
|
49
45
|
interface PromptOption<Value extends string> {
|
|
50
46
|
label: string;
|
|
51
47
|
value: Value;
|
|
@@ -441,15 +437,24 @@ const tui: TuiPlugin.Definition = {
|
|
|
441
437
|
const verb = entry.operation
|
|
442
438
|
? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
|
|
443
439
|
: 'Network';
|
|
440
|
+
const isUnixSocket = path.isAbsolute(entry.target);
|
|
444
441
|
return jsx(LandstripPermissionPrompt, {
|
|
445
442
|
icon: '%',
|
|
446
443
|
title: `${verb} ${entry.target}`,
|
|
447
|
-
options:
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
444
|
+
options: isUnixSocket
|
|
445
|
+
? [
|
|
446
|
+
{ label: 'Allow once', value: 'once' },
|
|
447
|
+
{ label: 'Allow for session', value: 'session' },
|
|
448
|
+
{ label: 'Allow for project', value: 'project' },
|
|
449
|
+
{ label: 'Allow globally', value: 'global' },
|
|
450
|
+
{ label: 'Deny', value: 'deny' },
|
|
451
|
+
]
|
|
452
|
+
: [
|
|
453
|
+
{ label: 'Allow once', value: 'once' },
|
|
454
|
+
{ label: 'Allow for session', value: 'session' },
|
|
455
|
+
{ label: 'Deny', value: 'deny' },
|
|
456
|
+
],
|
|
457
|
+
onSelect: (choice: QueryChoice) => resolveNetworkQuery(entry, choice),
|
|
453
458
|
onCancel: () => resolveNetworkQuery(entry, 'deny'),
|
|
454
459
|
onShow: () => notifyQuery(entry),
|
|
455
460
|
});
|
|
@@ -469,19 +474,23 @@ const tui: TuiPlugin.Definition = {
|
|
|
469
474
|
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
470
475
|
const directory = entry.directory;
|
|
471
476
|
const config = loadConfig(directory, optionOverrides);
|
|
472
|
-
let scope = sessionScopeFor(entry.path, directory);
|
|
473
|
-
if (
|
|
474
|
-
entry.operation === 'read'
|
|
475
|
-
? !isPathReadAllowed(entry.path, [scope], config.filesystem.denyRead, directory, {
|
|
476
|
-
requireAllowMatch: true,
|
|
477
|
-
})
|
|
478
|
-
: !isPathWriteAllowed(entry.path, [scope], config.filesystem.denyWrite, directory)
|
|
479
|
-
) {
|
|
480
|
-
scope = entry.path;
|
|
481
|
-
}
|
|
482
477
|
const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
|
|
483
478
|
const sessionPaths =
|
|
484
479
|
entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
|
|
480
|
+
const scopes =
|
|
481
|
+
entry.operation === 'read'
|
|
482
|
+
? readGrantScopesFor(
|
|
483
|
+
entry.path,
|
|
484
|
+
[...config.filesystem.allowRead, ...allowances.readPaths],
|
|
485
|
+
config.filesystem.denyRead,
|
|
486
|
+
directory,
|
|
487
|
+
)
|
|
488
|
+
: writeGrantScopesFor(
|
|
489
|
+
entry.path,
|
|
490
|
+
[...config.filesystem.allowWrite, ...allowances.writePaths],
|
|
491
|
+
config.filesystem.denyWrite,
|
|
492
|
+
directory,
|
|
493
|
+
);
|
|
485
494
|
let responseAction: 'allow' | 'deny' = action;
|
|
486
495
|
try {
|
|
487
496
|
if (action === 'allow') {
|
|
@@ -489,10 +498,12 @@ const tui: TuiPlugin.Definition = {
|
|
|
489
498
|
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
490
499
|
writeConfigFile(choice === 'project' ? projectPath : globalPath, {
|
|
491
500
|
filesystem:
|
|
492
|
-
entry.operation === 'read' ? { allowRead:
|
|
501
|
+
entry.operation === 'read' ? { allowRead: scopes } : { allowWrite: scopes },
|
|
493
502
|
});
|
|
494
503
|
}
|
|
495
|
-
if (choice !== 'once')
|
|
504
|
+
if (choice !== 'once') {
|
|
505
|
+
for (const scope of scopes) sessionPaths.add(scope);
|
|
506
|
+
}
|
|
496
507
|
}
|
|
497
508
|
} catch {
|
|
498
509
|
responseAction = 'deny';
|
|
@@ -505,7 +516,7 @@ const tui: TuiPlugin.Definition = {
|
|
|
505
516
|
message:
|
|
506
517
|
responseAction === 'deny'
|
|
507
518
|
? `${verb} denied: ${entry.path}`
|
|
508
|
-
: `${verb} allowed (${choice}) under ${
|
|
519
|
+
: `${verb} allowed (${choice}) under ${scopes.join(', ')}`,
|
|
509
520
|
variant: responseAction === 'deny' ? 'warning' : 'success',
|
|
510
521
|
});
|
|
511
522
|
} finally {
|
|
@@ -514,23 +525,36 @@ const tui: TuiPlugin.Definition = {
|
|
|
514
525
|
}
|
|
515
526
|
}
|
|
516
527
|
|
|
517
|
-
function resolveNetworkQuery(entry: NetworkQueryEntry, choice:
|
|
528
|
+
function resolveNetworkQuery(entry: NetworkQueryEntry, choice: QueryChoice): void {
|
|
518
529
|
if (resolved.has(entry.id)) return;
|
|
519
530
|
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
531
|
+
let responseAction: 'allow' | 'deny' = action;
|
|
520
532
|
|
|
521
533
|
try {
|
|
522
|
-
if (action === 'allow'
|
|
523
|
-
|
|
534
|
+
if (action === 'allow') {
|
|
535
|
+
if (path.isAbsolute(entry.target) && (choice === 'project' || choice === 'global')) {
|
|
536
|
+
const { globalPath, projectPath } = getConfigPaths(entry.directory);
|
|
537
|
+
writeConfigFile(choice === 'project' ? projectPath : globalPath, {
|
|
538
|
+
network: { allowUnixSockets: [entry.target] },
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
if (choice !== 'once') {
|
|
542
|
+
sessionAllowancesFor(sessionAllowances, entry.sessionID).targets.add(entry.target);
|
|
543
|
+
}
|
|
524
544
|
}
|
|
545
|
+
} catch {
|
|
546
|
+
responseAction = 'deny';
|
|
547
|
+
}
|
|
525
548
|
|
|
526
|
-
|
|
549
|
+
try {
|
|
550
|
+
respondQuery(entry.socket, entry.queryId, responseAction);
|
|
527
551
|
context.ui.toast.show({
|
|
528
552
|
title: 'Sandbox',
|
|
529
553
|
message:
|
|
530
|
-
|
|
554
|
+
responseAction === 'deny'
|
|
531
555
|
? `${entry.operation} denied: ${entry.target}`
|
|
532
556
|
: `${entry.operation} allowed (${choice}): ${entry.target}`,
|
|
533
|
-
variant:
|
|
557
|
+
variant: responseAction === 'deny' ? 'warning' : 'success',
|
|
534
558
|
});
|
|
535
559
|
} finally {
|
|
536
560
|
liveQueries.delete(entry);
|
|
@@ -605,12 +629,18 @@ const tui: TuiPlugin.Definition = {
|
|
|
605
629
|
trap.operation === 'read'
|
|
606
630
|
? evaluateReadAccess(
|
|
607
631
|
trap.path,
|
|
608
|
-
{
|
|
632
|
+
{
|
|
633
|
+
...config.filesystem,
|
|
634
|
+
allowRead: [...config.filesystem.allowRead, ...allowances.readPaths],
|
|
635
|
+
},
|
|
609
636
|
{ baseDirectory: sessionDirectory, requireAllowMatch: true },
|
|
610
637
|
) === 'allow'
|
|
611
638
|
: evaluateWriteAccess(
|
|
612
639
|
trap.path,
|
|
613
|
-
{
|
|
640
|
+
{
|
|
641
|
+
...config.filesystem,
|
|
642
|
+
allowWrite: [...config.filesystem.allowWrite, ...allowances.writePaths],
|
|
643
|
+
},
|
|
614
644
|
{ baseDirectory: sessionDirectory },
|
|
615
645
|
) === 'allow';
|
|
616
646
|
if (sessionAllowed) {
|
|
@@ -631,11 +661,19 @@ const tui: TuiPlugin.Definition = {
|
|
|
631
661
|
liveQueries.add(entry);
|
|
632
662
|
enqueueEntry(entry);
|
|
633
663
|
} else {
|
|
634
|
-
|
|
664
|
+
const socketAllowed =
|
|
665
|
+
allowances.targets.has(trap.target) ||
|
|
666
|
+
(path.isAbsolute(trap.target) &&
|
|
667
|
+
(config.network.allowAllUnixSockets ||
|
|
668
|
+
matchesPathPattern(
|
|
669
|
+
trap.target,
|
|
670
|
+
config.network.allowUnixSockets,
|
|
671
|
+
sessionDirectory,
|
|
672
|
+
)));
|
|
673
|
+
if (socketAllowed) {
|
|
635
674
|
respondQuery(socket, trap.query_id, 'allow');
|
|
636
675
|
continue;
|
|
637
676
|
}
|
|
638
|
-
|
|
639
677
|
const entry: NetworkQueryEntry = {
|
|
640
678
|
kind: 'net-query',
|
|
641
679
|
id: `landstrip-net:${socketId}:${trap.query_id}`,
|