opencode-landstrip 0.18.35 → 0.18.36
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 +4 -2
- package/index.ts +56 -467
- package/package.json +2 -2
- package/shared.ts +87 -33
- package/tui.ts +159 -41
package/README.md
CHANGED
|
@@ -20,8 +20,10 @@ OpenCode permissions authorize tool dispatch. Landstrip permissions authorize
|
|
|
20
20
|
filesystem and network access. Agent approval never bypasses a sandbox hard
|
|
21
21
|
denial, and requests without a live presenter are denied.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
OpenCode intentionally exposes one plugin slash command: `/landstrip`, which
|
|
24
|
+
opens the bottom management pane. Pi-only agent and task subcommands are not
|
|
25
|
+
available. Persistent approvals update `.opencode/sandbox.json` or
|
|
26
|
+
`~/.config/opencode/sandbox.json`.
|
|
25
27
|
|
|
26
28
|
## Configuration
|
|
27
29
|
|
package/index.ts
CHANGED
|
@@ -5,40 +5,35 @@ import type { Hooks, Plugin, PluginInput, PluginOptions } from '@opencode-ai/plu
|
|
|
5
5
|
|
|
6
6
|
import { randomBytes } from 'node:crypto';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
type AddressInfo,
|
|
12
|
-
BlockList,
|
|
13
|
-
connect as connectNet,
|
|
14
|
-
createServer,
|
|
15
|
-
isIP,
|
|
16
|
-
type Socket,
|
|
17
|
-
} from 'node:net';
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { type AddressInfo, connect as connectNet, createServer } from 'node:net';
|
|
18
10
|
import { tmpdir } from 'node:os';
|
|
19
|
-
import { basename,
|
|
20
|
-
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { startFilterProxy } from '@landstrip/landstrip/proxy';
|
|
21
14
|
|
|
22
15
|
import {
|
|
23
16
|
type LandstripTrap,
|
|
24
17
|
type SandboxConfig,
|
|
25
18
|
type SandboxFilesystemConfig,
|
|
26
19
|
allowsAllDomains,
|
|
20
|
+
canonicalizeGlobPattern,
|
|
21
|
+
canonicalizePath,
|
|
27
22
|
controlResponseLine,
|
|
28
23
|
decodeLandstripTrap,
|
|
29
24
|
domainMatchesAny,
|
|
30
|
-
expandHomePath,
|
|
31
25
|
extractDomainsFromCommand,
|
|
32
26
|
formatLandstripTraps,
|
|
33
27
|
getConfigPaths,
|
|
28
|
+
globToRegExp,
|
|
34
29
|
isRecord,
|
|
35
30
|
landstripBinaryPath,
|
|
36
31
|
loadConfig,
|
|
37
32
|
normalizeOptions,
|
|
33
|
+
normalizePathSeparators,
|
|
38
34
|
parseLandstripTraps,
|
|
39
35
|
permissionPatterns,
|
|
40
36
|
permissionType,
|
|
41
|
-
sandboxSummary,
|
|
42
37
|
readDiscoveryPort,
|
|
43
38
|
trapSessionHelloLine,
|
|
44
39
|
} from './shared.js';
|
|
@@ -79,121 +74,13 @@ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
|
79
74
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
80
75
|
const DISCOVERY_CONNECT_TIMEOUT_MS = 250;
|
|
81
76
|
|
|
82
|
-
const prohibitedProxyAddresses = new BlockList();
|
|
83
|
-
|
|
84
|
-
for (const [network, prefix] of [
|
|
85
|
-
['0.0.0.0', 8],
|
|
86
|
-
['10.0.0.0', 8],
|
|
87
|
-
['100.64.0.0', 10],
|
|
88
|
-
['127.0.0.0', 8],
|
|
89
|
-
['169.254.0.0', 16],
|
|
90
|
-
['172.16.0.0', 12],
|
|
91
|
-
['192.0.0.0', 24],
|
|
92
|
-
['192.168.0.0', 16],
|
|
93
|
-
['198.18.0.0', 15],
|
|
94
|
-
['224.0.0.0', 4],
|
|
95
|
-
['240.0.0.0', 4],
|
|
96
|
-
] as const) {
|
|
97
|
-
prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv4');
|
|
98
|
-
}
|
|
99
|
-
for (const [network, prefix] of [
|
|
100
|
-
['::', 128],
|
|
101
|
-
['::1', 128],
|
|
102
|
-
['fc00::', 7],
|
|
103
|
-
['fe80::', 10],
|
|
104
|
-
['ff00::', 8],
|
|
105
|
-
] as const) {
|
|
106
|
-
prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv6');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function expandPath(filePath: string, baseDirectory: string): string {
|
|
110
|
-
const expanded = expandHomePath(filePath);
|
|
111
|
-
return resolve(isAbsolute(expanded) ? expanded : join(baseDirectory, expanded));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
77
|
function configuredShellPath(config: unknown): string | undefined {
|
|
115
78
|
if (!isRecord(config)) return undefined;
|
|
116
79
|
return typeof config.shell === 'string' ? config.shell : undefined;
|
|
117
80
|
}
|
|
118
81
|
|
|
119
|
-
function canonicalizePath(filePath: string, baseDirectory: string): string {
|
|
120
|
-
const abs = expandPath(filePath, baseDirectory);
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
return realpathSync.native(abs);
|
|
124
|
-
} catch {
|
|
125
|
-
const tail: string[] = [];
|
|
126
|
-
let probe = abs;
|
|
127
|
-
|
|
128
|
-
while (!existsSync(probe)) {
|
|
129
|
-
const parent = dirname(probe);
|
|
130
|
-
if (parent === probe) return abs;
|
|
131
|
-
tail.unshift(basename(probe));
|
|
132
|
-
probe = parent;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
return resolve(realpathSync.native(probe), ...tail);
|
|
137
|
-
} catch {
|
|
138
|
-
return abs;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function canonicalizeGlobPath(pattern: string, baseDirectory: string): string {
|
|
144
|
-
const abs = expandPath(pattern, baseDirectory);
|
|
145
|
-
const wildcardIndex = abs.indexOf('*');
|
|
146
|
-
if (wildcardIndex === -1) return canonicalizePath(abs, baseDirectory);
|
|
147
|
-
|
|
148
|
-
const prefixEnd = abs.lastIndexOf(sep, wildcardIndex);
|
|
149
|
-
const root = parse(abs).root;
|
|
150
|
-
const wildcardAtRoot = prefixEnd < root.length;
|
|
151
|
-
const prefix = wildcardAtRoot ? root : abs.slice(0, prefixEnd);
|
|
152
|
-
const suffixStart = wildcardAtRoot ? root.length : prefixEnd;
|
|
153
|
-
return canonicalizePath(prefix, baseDirectory) + abs.slice(suffixStart);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
82
|
function normalizePathForMatch(filePath: string): string {
|
|
157
|
-
return process.platform === 'win32' ? filePath
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const globRegExpCache = new Map<string, RegExp>();
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Translates an absolute glob pattern to a regular expression using standard
|
|
164
|
-
* path semantics: `**` crosses directory boundaries (and `**/` may match zero
|
|
165
|
-
* segments), while a single `*` is confined to one path segment.
|
|
166
|
-
*/
|
|
167
|
-
function globToRegExp(globPattern: string): RegExp {
|
|
168
|
-
const cached = globRegExpCache.get(globPattern);
|
|
169
|
-
if (cached) return cached;
|
|
170
|
-
|
|
171
|
-
let regex = '';
|
|
172
|
-
|
|
173
|
-
for (let i = 0; i < globPattern.length; i++) {
|
|
174
|
-
const char = globPattern.charAt(i);
|
|
175
|
-
if (char === '*') {
|
|
176
|
-
if (globPattern.charAt(i + 1) === '*') {
|
|
177
|
-
i++;
|
|
178
|
-
if (globPattern.charAt(i + 1) === '/') {
|
|
179
|
-
i++;
|
|
180
|
-
regex += '(?:.*/)?';
|
|
181
|
-
} else {
|
|
182
|
-
regex += '.*';
|
|
183
|
-
}
|
|
184
|
-
} else {
|
|
185
|
-
regex += '[^/]*';
|
|
186
|
-
}
|
|
187
|
-
} else if (/[.+^${}()|[\]\\]/.test(char)) {
|
|
188
|
-
regex += `\\${char}`;
|
|
189
|
-
} else {
|
|
190
|
-
regex += char;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const result = new RegExp(`^${regex}$`);
|
|
195
|
-
globRegExpCache.set(globPattern, result);
|
|
196
|
-
return result;
|
|
83
|
+
return process.platform === 'win32' ? normalizePathSeparators(filePath).toLowerCase() : filePath;
|
|
197
84
|
}
|
|
198
85
|
|
|
199
86
|
// Component count of an absolute path; "/" is 0. Used to rank how specific a
|
|
@@ -211,7 +98,7 @@ function matchDepth(filePath: string, patterns: string[], baseDirectory: string)
|
|
|
211
98
|
|
|
212
99
|
for (const pattern of patterns) {
|
|
213
100
|
if (pattern.includes('*')) {
|
|
214
|
-
const absPattern = normalizePathForMatch(
|
|
101
|
+
const absPattern = normalizePathForMatch(canonicalizeGlobPattern(pattern, baseDirectory));
|
|
215
102
|
if (globToRegExp(absPattern).test(abs)) depth = Math.max(depth, pathDepth(abs));
|
|
216
103
|
} else {
|
|
217
104
|
const absPattern = normalizePathForMatch(canonicalizePath(pattern, baseDirectory));
|
|
@@ -228,7 +115,7 @@ function matchDepth(filePath: string, patterns: string[], baseDirectory: string)
|
|
|
228
115
|
function resolveFilesystemPatterns(patterns: string[], baseDirectory: string): string[] {
|
|
229
116
|
return patterns.map((pattern) =>
|
|
230
117
|
pattern.includes('*')
|
|
231
|
-
?
|
|
118
|
+
? canonicalizeGlobPattern(pattern, baseDirectory)
|
|
232
119
|
: canonicalizePath(pattern, baseDirectory),
|
|
233
120
|
);
|
|
234
121
|
}
|
|
@@ -450,76 +337,6 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
450
337
|
return true;
|
|
451
338
|
}
|
|
452
339
|
|
|
453
|
-
function parseProxyPort(value: string | undefined, defaultPort: number): number | null {
|
|
454
|
-
const rawPort = value ?? String(defaultPort);
|
|
455
|
-
if (!/^\d+$/.test(rawPort)) return null;
|
|
456
|
-
|
|
457
|
-
const port = Number(rawPort);
|
|
458
|
-
return port >= 1 && port <= 65535 ? port : null;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
462
|
-
const bracketMatch = target.match(/^\[([^\]]+)\](?::(.*))?$/);
|
|
463
|
-
const host = bracketMatch?.[1];
|
|
464
|
-
if (host) {
|
|
465
|
-
const port = parseProxyPort(bracketMatch?.[2], defaultPort);
|
|
466
|
-
return port === null ? null : { host, port };
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const lastColon = target.lastIndexOf(':');
|
|
470
|
-
if (lastColon > -1 && target.indexOf(':') === lastColon) {
|
|
471
|
-
const port = parseProxyPort(target.slice(lastColon + 1), defaultPort);
|
|
472
|
-
return port === null ? null : { host: target.slice(0, lastColon), port };
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
return { host: target, port: defaultPort };
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function denyProxyRequest(client: Socket, status = '403 Forbidden'): void {
|
|
479
|
-
client.write(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`);
|
|
480
|
-
client.end();
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function isPublicProxyAddress(address: string, family = isIP(address)): boolean {
|
|
484
|
-
if (family === 4) return !prohibitedProxyAddresses.check(address, 'ipv4');
|
|
485
|
-
if (family === 6) return !prohibitedProxyAddresses.check(address, 'ipv6');
|
|
486
|
-
return false;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async function resolveProxyEndpoint(host: string): Promise<{ address: string; family: 4 | 6 }> {
|
|
490
|
-
const literalFamily = isIP(host);
|
|
491
|
-
if (literalFamily === 4 || literalFamily === 6) {
|
|
492
|
-
if (!isPublicProxyAddress(host, literalFamily)) {
|
|
493
|
-
throw new Error(`Proxy destination is not public: ${host}`);
|
|
494
|
-
}
|
|
495
|
-
return { address: host, family: literalFamily };
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
const addresses = await lookup(host, { all: true, verbatim: true });
|
|
499
|
-
if (
|
|
500
|
-
addresses.length === 0 ||
|
|
501
|
-
addresses.some(({ address, family }) => !isPublicProxyAddress(address, family))
|
|
502
|
-
) {
|
|
503
|
-
throw new Error(`Proxy destination resolves to a non-public address: ${host}`);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const endpoint = addresses[0];
|
|
507
|
-
if (!endpoint || (endpoint.family !== 4 && endpoint.family !== 6)) {
|
|
508
|
-
throw new Error(`Proxy could not resolve destination: ${host}`);
|
|
509
|
-
}
|
|
510
|
-
return { address: endpoint.address, family: endpoint.family };
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): void {
|
|
514
|
-
upstream.on('error', () => client.destroy());
|
|
515
|
-
client.on('error', () => upstream.destroy());
|
|
516
|
-
|
|
517
|
-
if (initialData?.length) upstream.write(initialData);
|
|
518
|
-
|
|
519
|
-
client.pipe(upstream);
|
|
520
|
-
upstream.pipe(client);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
340
|
function buildLandstripPolicy(
|
|
524
341
|
config: SandboxConfig,
|
|
525
342
|
baseDirectory: string,
|
|
@@ -552,191 +369,6 @@ function writePolicyFile(
|
|
|
552
369
|
return { dir, path };
|
|
553
370
|
}
|
|
554
371
|
|
|
555
|
-
function startProxy(
|
|
556
|
-
config: SandboxConfig,
|
|
557
|
-
authorization?: string,
|
|
558
|
-
): Promise<{ port: number; stop: () => Promise<void> }> {
|
|
559
|
-
const sockets = new Set<Socket>();
|
|
560
|
-
|
|
561
|
-
// Track the upstream socket so stop() tears it down, and abandon a still-
|
|
562
|
-
// connecting upstream when its client goes away — otherwise a connect to a
|
|
563
|
-
// black-holed host lingers in SYN-retry (~2 min), leaking an fd per request.
|
|
564
|
-
function trackUpstream(upstream: Socket, client: Socket, settled: () => boolean): void {
|
|
565
|
-
sockets.add(upstream);
|
|
566
|
-
upstream.once('close', () => sockets.delete(upstream));
|
|
567
|
-
client.once('close', () => {
|
|
568
|
-
if (!settled()) upstream.destroy();
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
async function handleConnect(client: Socket, target: string, rest: Buffer): Promise<void> {
|
|
573
|
-
const endpoint = splitHostPort(target, 443);
|
|
574
|
-
if (!endpoint) {
|
|
575
|
-
denyProxyRequest(client, '400 Bad Request');
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
if (!isDomainAllowed(endpoint.host, config)) {
|
|
580
|
-
denyProxyRequest(client);
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const resolved = await resolveProxyEndpoint(endpoint.host);
|
|
585
|
-
let settled = false;
|
|
586
|
-
const upstream = connectNet(
|
|
587
|
-
{ host: resolved.address, port: endpoint.port, family: resolved.family },
|
|
588
|
-
() => {
|
|
589
|
-
settled = true;
|
|
590
|
-
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
591
|
-
pipeSockets(client, upstream, rest);
|
|
592
|
-
},
|
|
593
|
-
);
|
|
594
|
-
trackUpstream(upstream, client, () => settled);
|
|
595
|
-
upstream.once('error', () => {
|
|
596
|
-
if (settled) return;
|
|
597
|
-
settled = true;
|
|
598
|
-
denyProxyRequest(client, '502 Bad Gateway');
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
|
|
603
|
-
const lines = headerText.split(/\r?\n/);
|
|
604
|
-
const requestLine = lines[0];
|
|
605
|
-
if (!requestLine) {
|
|
606
|
-
denyProxyRequest(client, '400 Bad Request');
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const [method, rawTarget, version] = requestLine.split(' ');
|
|
611
|
-
|
|
612
|
-
if (!method || !rawTarget || !version) {
|
|
613
|
-
denyProxyRequest(client, '400 Bad Request');
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
let url: URL;
|
|
618
|
-
try {
|
|
619
|
-
url = new URL(rawTarget);
|
|
620
|
-
} catch {
|
|
621
|
-
const host = lines
|
|
622
|
-
.find((line) => line.toLowerCase().startsWith('host:'))
|
|
623
|
-
?.slice(5)
|
|
624
|
-
.trim();
|
|
625
|
-
if (!host) {
|
|
626
|
-
denyProxyRequest(client, '400 Bad Request');
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
url = new URL(`http://${host}${rawTarget}`);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
if (!isDomainAllowed(url.hostname, config)) {
|
|
633
|
-
denyProxyRequest(client);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const port = parseProxyPort(url.port || undefined, url.protocol === 'https:' ? 443 : 80);
|
|
638
|
-
if (port === null) {
|
|
639
|
-
denyProxyRequest(client, '400 Bad Request');
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const path = `${url.pathname}${url.search}` || '/';
|
|
644
|
-
lines[0] = `${method} ${path} ${version}`;
|
|
645
|
-
|
|
646
|
-
const rewrittenHeader = lines
|
|
647
|
-
.filter(
|
|
648
|
-
(line) =>
|
|
649
|
-
!line.toLowerCase().startsWith('proxy-connection:') &&
|
|
650
|
-
!line.toLowerCase().startsWith('proxy-authorization:'),
|
|
651
|
-
)
|
|
652
|
-
.join('\r\n');
|
|
653
|
-
const resolved = await resolveProxyEndpoint(url.hostname);
|
|
654
|
-
let settled = false;
|
|
655
|
-
const upstream = connectNet({ host: resolved.address, port, family: resolved.family }, () => {
|
|
656
|
-
settled = true;
|
|
657
|
-
upstream.write(`${rewrittenHeader}\r\n\r\n`);
|
|
658
|
-
pipeSockets(client, upstream, rest);
|
|
659
|
-
});
|
|
660
|
-
trackUpstream(upstream, client, () => settled);
|
|
661
|
-
upstream.once('error', () => {
|
|
662
|
-
if (settled) return;
|
|
663
|
-
settled = true;
|
|
664
|
-
denyProxyRequest(client, '502 Bad Gateway');
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
function handleClient(client: Socket): void {
|
|
669
|
-
sockets.add(client);
|
|
670
|
-
client.on('close', () => sockets.delete(client));
|
|
671
|
-
client.on('error', () => sockets.delete(client));
|
|
672
|
-
|
|
673
|
-
let buffered = Buffer.alloc(0);
|
|
674
|
-
|
|
675
|
-
client.on('data', (chunk: Buffer) => {
|
|
676
|
-
buffered = Buffer.concat([buffered, chunk]);
|
|
677
|
-
const headerEnd = buffered.indexOf('\r\n\r\n');
|
|
678
|
-
if (headerEnd === -1) {
|
|
679
|
-
if (buffered.length > 65536) {
|
|
680
|
-
client.removeAllListeners('data');
|
|
681
|
-
client.pause();
|
|
682
|
-
denyProxyRequest(client, '431 Request Header Fields Too Large');
|
|
683
|
-
}
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
client.pause();
|
|
688
|
-
client.removeAllListeners('data');
|
|
689
|
-
|
|
690
|
-
const header = buffered.subarray(0, headerEnd).toString('utf-8');
|
|
691
|
-
const rest = buffered.subarray(headerEnd + 4);
|
|
692
|
-
if (authorization) {
|
|
693
|
-
const supplied = header
|
|
694
|
-
.split(/\r?\n/)
|
|
695
|
-
.find((line) => line.toLowerCase().startsWith('proxy-authorization:'))
|
|
696
|
-
?.slice('proxy-authorization:'.length)
|
|
697
|
-
.trim();
|
|
698
|
-
if (supplied !== authorization) {
|
|
699
|
-
denyProxyRequest(client, '407 Proxy Authentication Required');
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
const firstLine = header.split(/\r?\n/, 1)[0];
|
|
704
|
-
const [method, target] = (firstLine ?? '').split(' ');
|
|
705
|
-
|
|
706
|
-
const task =
|
|
707
|
-
method?.toUpperCase() === 'CONNECT' && target
|
|
708
|
-
? handleConnect(client, target, rest)
|
|
709
|
-
: handleHttp(client, header, rest);
|
|
710
|
-
task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
const server = createServer(handleClient);
|
|
715
|
-
let stopped = false;
|
|
716
|
-
|
|
717
|
-
return new Promise((resolvePromise, reject) => {
|
|
718
|
-
server.on('error', reject);
|
|
719
|
-
server.listen(0, '127.0.0.1', () => {
|
|
720
|
-
server.removeListener('error', reject);
|
|
721
|
-
const address = server.address() as AddressInfo;
|
|
722
|
-
|
|
723
|
-
resolvePromise({
|
|
724
|
-
port: address.port,
|
|
725
|
-
stop: () =>
|
|
726
|
-
new Promise<void>((done) => {
|
|
727
|
-
if (stopped) {
|
|
728
|
-
done();
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
stopped = true;
|
|
732
|
-
for (const socket of sockets) socket.destroy();
|
|
733
|
-
server.close(() => done());
|
|
734
|
-
}),
|
|
735
|
-
});
|
|
736
|
-
});
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
|
|
740
372
|
function proxyEnv(
|
|
741
373
|
port: number | null,
|
|
742
374
|
proxyToken?: string | null,
|
|
@@ -1008,8 +640,12 @@ function evaluateToolPermissions(
|
|
|
1008
640
|
}
|
|
1009
641
|
|
|
1010
642
|
if (tool === 'write' || tool === 'edit') {
|
|
1011
|
-
const
|
|
1012
|
-
|
|
643
|
+
const paths = Array.isArray(args.paths)
|
|
644
|
+
? args.paths.filter((path): path is string => typeof path === 'string')
|
|
645
|
+
: [getToolPath(args)].filter((path): path is string => path !== undefined);
|
|
646
|
+
return paths.map((path) =>
|
|
647
|
+
evaluateWritePermission(path, config, baseDirectory, effectiveAllowWrite),
|
|
648
|
+
);
|
|
1013
649
|
}
|
|
1014
650
|
|
|
1015
651
|
if (tool === 'apply_patch' && typeof args.patchText === 'string') {
|
|
@@ -1043,12 +679,20 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1043
679
|
return `${callID}:${kind}:${resource}`;
|
|
1044
680
|
}
|
|
1045
681
|
|
|
1046
|
-
function
|
|
682
|
+
function rememberCallAllowances(
|
|
1047
683
|
callID: string | undefined,
|
|
1048
|
-
|
|
684
|
+
approvals: readonly SandboxPermissionDecision[],
|
|
1049
685
|
): void {
|
|
1050
|
-
if (!callID
|
|
1051
|
-
|
|
686
|
+
if (!callID) return;
|
|
687
|
+
for (const approval of approvals) {
|
|
688
|
+
callAllowances.add(allowanceKey(callID, approval.kind, approval.resource));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function clearCallAllowances(callID: string): void {
|
|
693
|
+
for (const key of callAllowances) {
|
|
694
|
+
if (key.startsWith(`${callID}:`)) callAllowances.delete(key);
|
|
695
|
+
}
|
|
1052
696
|
}
|
|
1053
697
|
|
|
1054
698
|
function hasCallAllowance(callID: string, decision: SandboxPermissionDecision): boolean {
|
|
@@ -1073,26 +717,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1073
717
|
reportBlocked(decision);
|
|
1074
718
|
}
|
|
1075
719
|
|
|
1076
|
-
function pushCommandText(
|
|
1077
|
-
input: { sessionID: string },
|
|
1078
|
-
output: { parts: unknown[] },
|
|
1079
|
-
text: string,
|
|
1080
|
-
): void {
|
|
1081
|
-
output.parts.push({
|
|
1082
|
-
type: 'text',
|
|
1083
|
-
text,
|
|
1084
|
-
id: '',
|
|
1085
|
-
sessionID: input.sessionID,
|
|
1086
|
-
messageID: '',
|
|
1087
|
-
});
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
function buildSandboxSummary(config: SandboxConfig): string {
|
|
1091
|
-
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
1092
|
-
const report = sandboxSummary(config, globalPath, projectPath);
|
|
1093
|
-
return ['# Sandbox Configuration', '', report].join('\n');
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
720
|
client.app
|
|
1097
721
|
?.log?.({
|
|
1098
722
|
body: {
|
|
@@ -1241,13 +865,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1241
865
|
}
|
|
1242
866
|
|
|
1243
867
|
async function cleanupBash(callID: string): Promise<void> {
|
|
868
|
+
clearCallAllowances(callID);
|
|
1244
869
|
const state = activeBash.get(callID);
|
|
1245
870
|
if (!state) return;
|
|
1246
871
|
|
|
1247
|
-
for (const key of callAllowances) {
|
|
1248
|
-
if (key.startsWith(`${callID}:`)) callAllowances.delete(key);
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
872
|
activeBash.delete(callID);
|
|
1252
873
|
if (state.stop) await state.stop().catch(() => undefined);
|
|
1253
874
|
if (state.trapServer) {
|
|
@@ -1323,7 +944,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1323
944
|
proxyToken === null
|
|
1324
945
|
? undefined
|
|
1325
946
|
: `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString('base64')}`;
|
|
1326
|
-
const proxy = allowNetwork
|
|
947
|
+
const proxy = allowNetwork
|
|
948
|
+
? null
|
|
949
|
+
: await startFilterProxy({
|
|
950
|
+
isDomainAllowed: (domain) => isDomainAllowed(domain, effectiveConfig),
|
|
951
|
+
...(proxyAuthorization === undefined ? {} : { authorization: proxyAuthorization }),
|
|
952
|
+
});
|
|
1327
953
|
const proxyPort = proxy ? proxy.port : null;
|
|
1328
954
|
let policy: { dir: string; path: string };
|
|
1329
955
|
|
|
@@ -1387,10 +1013,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1387
1013
|
|
|
1388
1014
|
'permission.ask': async (input, output) => {
|
|
1389
1015
|
const config = await activeConfig();
|
|
1390
|
-
if (!config)
|
|
1391
|
-
output.status = 'allow';
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1016
|
+
if (!config) return;
|
|
1394
1017
|
|
|
1395
1018
|
const request = input as Record<string, unknown>;
|
|
1396
1019
|
const permission = permissionType(request);
|
|
@@ -1409,7 +1032,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1409
1032
|
const args: Record<string, unknown> = { ...metadata };
|
|
1410
1033
|
if (permission === 'read') args.paths = patterns;
|
|
1411
1034
|
if (permission === 'edit') {
|
|
1412
|
-
args.
|
|
1035
|
+
args.paths =
|
|
1036
|
+
patterns.length > 0
|
|
1037
|
+
? patterns
|
|
1038
|
+
: [metadata.filepath].filter((path): path is string => typeof path === 'string');
|
|
1413
1039
|
}
|
|
1414
1040
|
if (permission === 'bash' && typeof args.command !== 'string') args.command = patterns[0];
|
|
1415
1041
|
const decisions = evaluateToolPermissions(
|
|
@@ -1421,13 +1047,17 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1421
1047
|
effectiveAllowWrite,
|
|
1422
1048
|
);
|
|
1423
1049
|
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1050
|
+
const denied = decisions.find((item) => item.status === 'deny');
|
|
1051
|
+
if (denied) {
|
|
1052
|
+
output.status = 'deny';
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const approvals = decisions.filter((item) => item.status === 'ask');
|
|
1057
|
+
if (approvals.length === 0) return;
|
|
1428
1058
|
|
|
1429
|
-
output.status =
|
|
1430
|
-
|
|
1059
|
+
output.status = 'ask';
|
|
1060
|
+
rememberCallAllowances(callID, approvals);
|
|
1431
1061
|
},
|
|
1432
1062
|
|
|
1433
1063
|
'tool.execute.before': async (input, output) => {
|
|
@@ -1464,7 +1094,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1464
1094
|
},
|
|
1465
1095
|
|
|
1466
1096
|
'tool.execute.after': async (input, output) => {
|
|
1467
|
-
if (input.tool !== 'bash')
|
|
1097
|
+
if (input.tool !== 'bash') {
|
|
1098
|
+
clearCallAllowances(input.callID);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1468
1101
|
|
|
1469
1102
|
const state = activeBash.get(input.callID);
|
|
1470
1103
|
if (!state) {
|
|
@@ -1521,50 +1154,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1521
1154
|
await cleanupBash(input.callID);
|
|
1522
1155
|
},
|
|
1523
1156
|
|
|
1524
|
-
'command.execute.before': async (input, output) => {
|
|
1525
|
-
// OpenCode strips the leading slash before dispatching commands, so the
|
|
1526
|
-
// hook receives the bare name ("sandbox"); accept both forms so the
|
|
1527
|
-
// handler matches whether invoked by name or via tui.executeCommand.
|
|
1528
|
-
const command = input.command.trim().replace(/^\//, '');
|
|
1529
|
-
if (command === 'sandbox') {
|
|
1530
|
-
const config = loadConfig(directory, optionOverrides);
|
|
1531
|
-
pushCommandText(input, output, buildSandboxSummary(config));
|
|
1532
|
-
await client.tui
|
|
1533
|
-
?.showToast?.({
|
|
1534
|
-
body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
|
|
1535
|
-
})
|
|
1536
|
-
?.catch?.(() => undefined);
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
// Check domain and filesystem in user shell commands (commands starting with !)
|
|
1541
|
-
if (input.command.startsWith('!')) {
|
|
1542
|
-
const shellCommand = input.command.slice(1).trim();
|
|
1543
|
-
const config = await activeConfig();
|
|
1544
|
-
if (!config) return;
|
|
1545
|
-
|
|
1546
|
-
const effectiveAllowRead = config.filesystem.allowRead;
|
|
1547
|
-
const effectiveAllowWrite = config.filesystem.allowWrite;
|
|
1548
|
-
|
|
1549
|
-
for (const path of extractCandidatePaths(shellCommand)) {
|
|
1550
|
-
const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
|
|
1551
|
-
if (readDecision.status === 'deny') reportBlocked(readDecision);
|
|
1552
|
-
|
|
1553
|
-
const writeDecision = evaluateWritePermission(
|
|
1554
|
-
path,
|
|
1555
|
-
config,
|
|
1556
|
-
directory,
|
|
1557
|
-
effectiveAllowWrite,
|
|
1558
|
-
);
|
|
1559
|
-
if (writeDecision.status === 'deny') reportBlocked(writeDecision);
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
for (const decision of evaluateCommandDomains(shellCommand, config)) {
|
|
1563
|
-
if (decision.status !== 'allow') reportBlocked(decision);
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
},
|
|
1567
|
-
|
|
1568
1157
|
dispose: async () => {
|
|
1569
1158
|
await Promise.all([...activeBash.keys()].map((callID) => cleanupBash(callID)));
|
|
1570
1159
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.36",
|
|
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": "^0.18.
|
|
51
|
+
"@landstrip/landstrip": "^0.18.36",
|
|
52
52
|
"@opentui/solid": ">=0.3.4",
|
|
53
53
|
"solid-js": "1.9.12"
|
|
54
54
|
},
|
package/shared.ts
CHANGED
|
@@ -9,14 +9,19 @@ import {
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
allowsAllDomains,
|
|
12
|
+
canonicalizeGlobPattern,
|
|
13
|
+
canonicalizePath,
|
|
12
14
|
controlResponseLine,
|
|
13
15
|
decodeLandstripTrap,
|
|
14
16
|
domainMatchesAny,
|
|
15
17
|
domainMatchesPattern,
|
|
16
18
|
expandHomePath,
|
|
19
|
+
expandPath,
|
|
20
|
+
globToRegExp,
|
|
17
21
|
formatLandstripTrap,
|
|
18
22
|
formatLandstripTraps,
|
|
19
23
|
isRecord,
|
|
24
|
+
normalizePathSeparators,
|
|
20
25
|
parseLandstripTraps,
|
|
21
26
|
pathUnderDirectory,
|
|
22
27
|
sessionAllows,
|
|
@@ -25,14 +30,19 @@ import {
|
|
|
25
30
|
|
|
26
31
|
export {
|
|
27
32
|
allowsAllDomains,
|
|
33
|
+
canonicalizeGlobPattern,
|
|
34
|
+
canonicalizePath,
|
|
28
35
|
controlResponseLine,
|
|
29
36
|
decodeLandstripTrap,
|
|
30
37
|
domainMatchesAny,
|
|
31
38
|
domainMatchesPattern,
|
|
32
39
|
expandHomePath,
|
|
40
|
+
expandPath,
|
|
41
|
+
globToRegExp,
|
|
33
42
|
formatLandstripTrap,
|
|
34
43
|
formatLandstripTraps,
|
|
35
44
|
isRecord,
|
|
45
|
+
normalizePathSeparators,
|
|
36
46
|
parseLandstripTraps,
|
|
37
47
|
pathUnderDirectory,
|
|
38
48
|
sessionAllows,
|
|
@@ -93,57 +103,97 @@ export function list(values: string[]): string {
|
|
|
93
103
|
return values.join(', ') || '(none)';
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
function rejectUnknownFields(
|
|
107
|
+
value: Record<string, unknown>,
|
|
108
|
+
fields: readonly string[],
|
|
109
|
+
prefix = '',
|
|
110
|
+
): void {
|
|
111
|
+
for (const field of Object.keys(value)) {
|
|
112
|
+
if (!fields.includes(field)) throw new Error(`unknown sandbox field ${prefix}${field}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function booleanValue(value: unknown, field: string): boolean | undefined {
|
|
117
|
+
if (value === undefined) return undefined;
|
|
118
|
+
if (typeof value !== 'boolean') throw new Error(`${field} must be a boolean`);
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stringArray(value: unknown, field: string): string[] | undefined {
|
|
123
|
+
if (value === undefined) return undefined;
|
|
124
|
+
if (!Array.isArray(value) || [...value].some((item) => typeof item !== 'string')) {
|
|
125
|
+
throw new Error(`${field} must be an array of strings`);
|
|
126
|
+
}
|
|
127
|
+
return [...value];
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
|
|
102
|
-
if (
|
|
131
|
+
if (value === undefined) return undefined;
|
|
132
|
+
if (!isRecord(value)) throw new Error('network must be an object');
|
|
133
|
+
rejectUnknownFields(
|
|
134
|
+
value,
|
|
135
|
+
[
|
|
136
|
+
'allowNetwork',
|
|
137
|
+
'allowLocalBinding',
|
|
138
|
+
'allowAllUnixSockets',
|
|
139
|
+
'allowUnixSockets',
|
|
140
|
+
'allowedDomains',
|
|
141
|
+
'deniedDomains',
|
|
142
|
+
],
|
|
143
|
+
'network.',
|
|
144
|
+
);
|
|
103
145
|
|
|
104
146
|
const config: Partial<SandboxNetworkConfig> = {};
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
147
|
+
const allowNetwork = booleanValue(value.allowNetwork, 'network.allowNetwork');
|
|
148
|
+
if (allowNetwork !== undefined) config.allowNetwork = allowNetwork;
|
|
149
|
+
const allowLocalBinding = booleanValue(value.allowLocalBinding, 'network.allowLocalBinding');
|
|
150
|
+
if (allowLocalBinding !== undefined) config.allowLocalBinding = allowLocalBinding;
|
|
151
|
+
const allowAllUnixSockets = booleanValue(
|
|
152
|
+
value.allowAllUnixSockets,
|
|
153
|
+
'network.allowAllUnixSockets',
|
|
154
|
+
);
|
|
155
|
+
if (allowAllUnixSockets !== undefined) config.allowAllUnixSockets = allowAllUnixSockets;
|
|
110
156
|
|
|
111
|
-
const allowUnixSockets = stringArray(value.allowUnixSockets);
|
|
157
|
+
const allowUnixSockets = stringArray(value.allowUnixSockets, 'network.allowUnixSockets');
|
|
112
158
|
if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
|
|
113
159
|
|
|
114
|
-
const allowedDomains = stringArray(value.allowedDomains);
|
|
160
|
+
const allowedDomains = stringArray(value.allowedDomains, 'network.allowedDomains');
|
|
115
161
|
if (allowedDomains) config.allowedDomains = allowedDomains;
|
|
116
162
|
|
|
117
|
-
const deniedDomains = stringArray(value.deniedDomains);
|
|
163
|
+
const deniedDomains = stringArray(value.deniedDomains, 'network.deniedDomains');
|
|
118
164
|
if (deniedDomains) config.deniedDomains = deniedDomains;
|
|
119
165
|
|
|
120
166
|
return config;
|
|
121
167
|
}
|
|
122
168
|
|
|
123
169
|
function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
|
|
124
|
-
if (
|
|
170
|
+
if (value === undefined) return undefined;
|
|
171
|
+
if (!isRecord(value)) throw new Error('filesystem must be an object');
|
|
172
|
+
rejectUnknownFields(value, ['denyRead', 'allowRead', 'allowWrite', 'denyWrite'], 'filesystem.');
|
|
125
173
|
|
|
126
174
|
const config: Partial<SandboxFilesystemConfig> = {};
|
|
127
|
-
const denyRead = stringArray(value.denyRead);
|
|
175
|
+
const denyRead = stringArray(value.denyRead, 'filesystem.denyRead');
|
|
128
176
|
if (denyRead) config.denyRead = denyRead;
|
|
129
177
|
|
|
130
|
-
const allowRead = stringArray(value.allowRead);
|
|
178
|
+
const allowRead = stringArray(value.allowRead, 'filesystem.allowRead');
|
|
131
179
|
if (allowRead) config.allowRead = allowRead;
|
|
132
180
|
|
|
133
|
-
const allowWrite = stringArray(value.allowWrite);
|
|
181
|
+
const allowWrite = stringArray(value.allowWrite, 'filesystem.allowWrite');
|
|
134
182
|
if (allowWrite) config.allowWrite = allowWrite;
|
|
135
183
|
|
|
136
|
-
const denyWrite = stringArray(value.denyWrite);
|
|
184
|
+
const denyWrite = stringArray(value.denyWrite, 'filesystem.denyWrite');
|
|
137
185
|
if (denyWrite) config.denyWrite = denyWrite;
|
|
138
186
|
|
|
139
187
|
return config;
|
|
140
188
|
}
|
|
141
189
|
|
|
142
190
|
export function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
143
|
-
if (!isRecord(value))
|
|
191
|
+
if (!isRecord(value)) throw new Error('sandbox config must be an object');
|
|
192
|
+
rejectUnknownFields(value, ['enabled', 'network', 'filesystem']);
|
|
144
193
|
|
|
145
194
|
const config: SandboxConfigOverrides = {};
|
|
146
|
-
|
|
195
|
+
const enabled = booleanValue(value.enabled, 'enabled');
|
|
196
|
+
if (enabled !== undefined) config.enabled = enabled;
|
|
147
197
|
|
|
148
198
|
const network = normalizeNetworkConfig(value.network);
|
|
149
199
|
if (network) config.network = network;
|
|
@@ -156,7 +206,9 @@ export function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
|
156
206
|
|
|
157
207
|
export function normalizeOptions(options: unknown): SandboxConfigOverrides {
|
|
158
208
|
if (!isRecord(options)) return {};
|
|
159
|
-
|
|
209
|
+
if (options.config === undefined) return normalizeConfig(options);
|
|
210
|
+
if (!isRecord(options.config)) throw new Error('config must be an object');
|
|
211
|
+
return normalizeConfig(options.config);
|
|
160
212
|
}
|
|
161
213
|
|
|
162
214
|
function mergeArray(base: string[], override?: string[]): string[] {
|
|
@@ -194,15 +246,16 @@ export function getConfigPaths(baseDirectory: string): { globalPath: string; pro
|
|
|
194
246
|
};
|
|
195
247
|
}
|
|
196
248
|
|
|
197
|
-
|
|
198
|
-
// parsed, so callers can refuse to overwrite a corrupted config.
|
|
199
|
-
export function readConfigFile(configPath: string): SandboxConfigOverrides | null {
|
|
249
|
+
export function readConfigFile(configPath: string): SandboxConfigOverrides {
|
|
200
250
|
if (!existsSync(configPath)) return {};
|
|
201
251
|
|
|
202
252
|
try {
|
|
203
253
|
return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
204
|
-
} catch {
|
|
205
|
-
|
|
254
|
+
} catch (error) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Could not load ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
257
|
+
{ cause: error },
|
|
258
|
+
);
|
|
206
259
|
}
|
|
207
260
|
}
|
|
208
261
|
|
|
@@ -219,17 +272,14 @@ export function loadConfig(
|
|
|
219
272
|
}
|
|
220
273
|
|
|
221
274
|
const templateConfig: SandboxConfig = JSON.parse(readFileSync(templatePath, 'utf-8'));
|
|
222
|
-
const globalOverrides = readConfigFile(globalPath)
|
|
275
|
+
const globalOverrides = readConfigFile(globalPath);
|
|
223
276
|
const baseConfig = deepMerge(templateConfig, globalOverrides);
|
|
224
277
|
|
|
225
|
-
return deepMerge(deepMerge(baseConfig, readConfigFile(projectPath)
|
|
278
|
+
return deepMerge(deepMerge(baseConfig, readConfigFile(projectPath)), optionOverrides);
|
|
226
279
|
}
|
|
227
280
|
|
|
228
281
|
export function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
|
|
229
282
|
const current = readConfigFile(configPath);
|
|
230
|
-
if (current === null) {
|
|
231
|
-
throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
|
|
232
|
-
}
|
|
233
283
|
|
|
234
284
|
const templateConfig: SandboxConfig = JSON.parse(
|
|
235
285
|
readFileSync(join(packageDir, 'sandbox.json'), 'utf-8'),
|
|
@@ -482,7 +532,7 @@ export function discoveryFilePath(baseDirectory: string): string {
|
|
|
482
532
|
return join(discoveryDir(), `port-${directoryHash(baseDirectory)}.json`);
|
|
483
533
|
}
|
|
484
534
|
|
|
485
|
-
// /
|
|
535
|
+
// /landstrip toggles the persisted `enabled` flag. Write it where the setting
|
|
486
536
|
// already lives — the project config if it sets `enabled`, otherwise the global
|
|
487
537
|
// config — and return the scope written so the UI can report it.
|
|
488
538
|
export function sandboxConfigTarget(baseDirectory: string): {
|
|
@@ -490,8 +540,7 @@ export function sandboxConfigTarget(baseDirectory: string): {
|
|
|
490
540
|
path: string;
|
|
491
541
|
} {
|
|
492
542
|
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
493
|
-
const
|
|
494
|
-
const useProject = projectConfig !== null && projectConfig.enabled !== undefined;
|
|
543
|
+
const useProject = readConfigFile(projectPath).enabled !== undefined;
|
|
495
544
|
return useProject
|
|
496
545
|
? { scope: 'project', path: projectPath }
|
|
497
546
|
: { scope: 'global', path: globalPath };
|
|
@@ -500,7 +549,12 @@ export function sandboxConfigTarget(baseDirectory: string): {
|
|
|
500
549
|
export function setSandboxConfigEnabled(
|
|
501
550
|
baseDirectory: string,
|
|
502
551
|
enabled: boolean,
|
|
552
|
+
optionOverrides: SandboxConfigOverrides = {},
|
|
503
553
|
): 'project' | 'global' {
|
|
554
|
+
if (optionOverrides.enabled !== undefined) {
|
|
555
|
+
throw new Error('Sandbox state is managed by plugin options');
|
|
556
|
+
}
|
|
557
|
+
|
|
504
558
|
const target = sandboxConfigTarget(baseDirectory);
|
|
505
559
|
writeConfigFile(target.path, { enabled });
|
|
506
560
|
return target.scope;
|
package/tui.ts
CHANGED
|
@@ -125,6 +125,7 @@ function selectedForeground(background: RGBA): RGBA {
|
|
|
125
125
|
|
|
126
126
|
const tui: TuiPlugin = async (api, options, meta) => {
|
|
127
127
|
const optionOverrides = normalizeOptions(options);
|
|
128
|
+
const enabledManagedByOptions = optionOverrides.enabled !== undefined;
|
|
128
129
|
|
|
129
130
|
function LandstripPermissionPrompt(rawProps: Record<string, unknown>) {
|
|
130
131
|
const props = rawProps as unknown as PermissionPromptProps<string>;
|
|
@@ -147,9 +148,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
147
148
|
priority: 1000,
|
|
148
149
|
bindings: [
|
|
149
150
|
{ key: 'left', desc: 'Previous permission option', group: 'Sandbox', cmd: () => move(-1) },
|
|
150
|
-
{ key: 'h', desc: 'Previous permission option', group: 'Sandbox', cmd: () => move(-1) },
|
|
151
151
|
{ key: 'right', desc: 'Next permission option', group: 'Sandbox', cmd: () => move(1) },
|
|
152
|
-
{ key: 'l', desc: 'Next permission option', group: 'Sandbox', cmd: () => move(1) },
|
|
153
152
|
{ key: 'return', desc: 'Select permission option', group: 'Sandbox', cmd: submit },
|
|
154
153
|
{ key: 'escape', desc: 'Reject permission', group: 'Sandbox', cmd: props.onCancel },
|
|
155
154
|
{
|
|
@@ -690,58 +689,176 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
690
689
|
refreshPermissionPresentation,
|
|
691
690
|
);
|
|
692
691
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
692
|
+
const [landstripOpen, setLandstripOpen] = createSignal(false);
|
|
693
|
+
const [confirmingDisable, setConfirmingDisable] = createSignal(false);
|
|
694
|
+
const [sandboxRevision, setSandboxRevision] = createSignal(0);
|
|
695
|
+
let unregisterLandstripKeys: (() => void) | undefined;
|
|
696
|
+
let popLandstripMode: (() => void) | undefined;
|
|
697
|
+
|
|
698
|
+
const closeLandstrip = () => {
|
|
699
|
+
unregisterLandstripKeys?.();
|
|
700
|
+
popLandstripMode?.();
|
|
701
|
+
unregisterLandstripKeys = undefined;
|
|
702
|
+
popLandstripMode = undefined;
|
|
703
|
+
setConfirmingDisable(false);
|
|
704
|
+
setLandstripOpen(false);
|
|
705
|
+
};
|
|
706
|
+
const toggleLandstrip = () => {
|
|
707
|
+
if (enabledManagedByOptions) {
|
|
708
|
+
api.ui.toast({
|
|
709
|
+
title: 'Landstrip',
|
|
710
|
+
message: 'Sandbox state is managed by plugin options',
|
|
711
|
+
variant: 'warning',
|
|
712
|
+
});
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
697
716
|
const directory = api.state.path.directory || process.cwd();
|
|
698
717
|
const config = loadConfig(directory, optionOverrides);
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
718
|
+
if (config.enabled && !confirmingDisable()) {
|
|
719
|
+
setConfirmingDisable(true);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
setConfirmingDisable(false);
|
|
723
|
+
const enabled = !config.enabled;
|
|
724
|
+
const scope = setSandboxConfigEnabled(directory, enabled, optionOverrides);
|
|
725
|
+
refreshSandboxStatus?.();
|
|
726
|
+
api.ui.toast({
|
|
727
|
+
title: 'Landstrip',
|
|
728
|
+
message: `Sandbox ${enabled ? 'enabled' : 'disabled'} (${scope} config)`,
|
|
729
|
+
variant: enabled ? 'success' : 'warning',
|
|
730
|
+
});
|
|
731
|
+
};
|
|
732
|
+
const cancelLandstrip = () => {
|
|
733
|
+
if (confirmingDisable()) setConfirmingDisable(false);
|
|
734
|
+
else closeLandstrip();
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
function LandstripPane() {
|
|
738
|
+
const theme = api.theme.current;
|
|
739
|
+
const dimensions = useTerminalDimensions();
|
|
740
|
+
|
|
741
|
+
const row = (label: string, value: string) =>
|
|
742
|
+
jsxs('box', {
|
|
743
|
+
flexDirection: 'row',
|
|
744
|
+
paddingLeft: 2,
|
|
745
|
+
paddingRight: 2,
|
|
746
|
+
children: [
|
|
747
|
+
jsx('text', { fg: theme.textMuted, width: 22, children: label }),
|
|
748
|
+
jsx('text', { fg: theme.text, flexGrow: 1, wrapMode: 'word', children: value }),
|
|
749
|
+
],
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
return Portal({
|
|
753
|
+
get children() {
|
|
754
|
+
if (!landstripOpen()) return null;
|
|
755
|
+
sandboxRevision();
|
|
756
|
+
const directory = api.state.path.directory || process.cwd();
|
|
757
|
+
const config = loadConfig(directory, optionOverrides);
|
|
758
|
+
const target = sandboxConfigTarget(directory);
|
|
759
|
+
const values = (items: readonly string[]) => items.join(', ') || 'none';
|
|
760
|
+
const status = enabledManagedByOptions
|
|
761
|
+
? config.enabled
|
|
762
|
+
? 'Active (plugin options)'
|
|
763
|
+
: 'Disabled by plugin options'
|
|
764
|
+
: config.enabled
|
|
765
|
+
? 'Active'
|
|
766
|
+
: 'Disabled by configuration';
|
|
767
|
+
return jsxs('box', {
|
|
768
|
+
position: 'absolute',
|
|
769
|
+
zIndex: 4000,
|
|
770
|
+
bottom: 0,
|
|
771
|
+
left: 0,
|
|
772
|
+
right: 0,
|
|
773
|
+
get height() {
|
|
774
|
+
return Math.min(22, Math.max(12, dimensions().height - 2));
|
|
775
|
+
},
|
|
776
|
+
flexDirection: 'column',
|
|
777
|
+
border: ['top'],
|
|
778
|
+
borderColor: theme.primary,
|
|
779
|
+
backgroundColor: theme.backgroundPanel,
|
|
780
|
+
children: [
|
|
781
|
+
jsxs('box', {
|
|
782
|
+
flexDirection: 'row',
|
|
783
|
+
paddingLeft: 2,
|
|
784
|
+
paddingRight: 2,
|
|
785
|
+
paddingTop: 1,
|
|
786
|
+
paddingBottom: 1,
|
|
787
|
+
justifyContent: 'space-between',
|
|
788
|
+
children: [
|
|
789
|
+
jsx('text', { fg: theme.primary, children: 'Landstrip' }),
|
|
790
|
+
jsx('text', {
|
|
791
|
+
fg: config.enabled ? theme.success : theme.warning,
|
|
792
|
+
children: status,
|
|
793
|
+
}),
|
|
794
|
+
],
|
|
795
|
+
}),
|
|
796
|
+
row('Network', config.network.allowNetwork ? 'Unrestricted' : 'Proxied'),
|
|
797
|
+
row('Allowed Domains', values(config.network.allowedDomains)),
|
|
798
|
+
row('Denied Domains', values(config.network.deniedDomains)),
|
|
799
|
+
row('Allowed Reads', values(config.filesystem.allowRead)),
|
|
800
|
+
row('Denied Reads', values(config.filesystem.denyRead)),
|
|
801
|
+
row('Allowed Writes', values(config.filesystem.allowWrite)),
|
|
802
|
+
row('Denied Writes', values(config.filesystem.denyWrite)),
|
|
803
|
+
row(
|
|
804
|
+
'Configuration Scope',
|
|
805
|
+
enabledManagedByOptions
|
|
806
|
+
? 'Plugin options'
|
|
807
|
+
: target.scope === 'project'
|
|
808
|
+
? 'Project'
|
|
809
|
+
: 'Global',
|
|
810
|
+
),
|
|
811
|
+
row('Configuration File', formatPath(target.path, directory)),
|
|
812
|
+
jsx('box', { flexGrow: 1 }),
|
|
813
|
+
jsx('text', {
|
|
814
|
+
fg: confirmingDisable() ? theme.error : theme.textMuted,
|
|
815
|
+
marginLeft: 2,
|
|
816
|
+
marginRight: 2,
|
|
817
|
+
marginBottom: 1,
|
|
818
|
+
children: confirmingDisable()
|
|
819
|
+
? 'Disable the sandbox? Commands will run without OS isolation. Enter confirm · Esc cancel'
|
|
820
|
+
: enabledManagedByOptions
|
|
821
|
+
? 'Sandbox state is managed by plugin options · Esc close'
|
|
822
|
+
: `Enter ${config.enabled ? 'disable' : 'enable'} · Esc close`,
|
|
823
|
+
}),
|
|
824
|
+
],
|
|
825
|
+
});
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const showLandstrip = () => {
|
|
831
|
+
if (landstripOpen()) return;
|
|
832
|
+
setLandstripOpen(true);
|
|
833
|
+
popLandstripMode = api.mode.push('modal');
|
|
834
|
+
unregisterLandstripKeys = api.keymap.registerLayer({
|
|
835
|
+
priority: 1000,
|
|
836
|
+
bindings: [
|
|
837
|
+
{ key: 'return', desc: 'Toggle sandbox', group: 'Landstrip', cmd: toggleLandstrip },
|
|
838
|
+
{ key: 'escape', desc: 'Close Landstrip', group: 'Landstrip', cmd: cancelLandstrip },
|
|
839
|
+
],
|
|
840
|
+
});
|
|
724
841
|
};
|
|
842
|
+
api.lifecycle.onDispose(closeLandstrip);
|
|
725
843
|
|
|
726
844
|
api.keymap.registerLayer({
|
|
727
845
|
commands: [
|
|
728
846
|
{
|
|
729
847
|
namespace: 'palette',
|
|
730
|
-
name: '
|
|
731
|
-
title: '
|
|
732
|
-
desc: '
|
|
733
|
-
category: '
|
|
848
|
+
name: 'landstrip',
|
|
849
|
+
title: 'Landstrip',
|
|
850
|
+
desc: 'Manage the Landstrip sandbox',
|
|
851
|
+
category: 'Landstrip',
|
|
734
852
|
suggested: true,
|
|
735
|
-
slash: { name: '
|
|
736
|
-
slashName: '
|
|
737
|
-
run:
|
|
853
|
+
slash: { name: 'landstrip' },
|
|
854
|
+
slashName: 'landstrip',
|
|
855
|
+
run: showLandstrip,
|
|
738
856
|
},
|
|
739
857
|
],
|
|
740
858
|
});
|
|
741
859
|
|
|
742
860
|
// Persistent status badge in the prompt area.
|
|
743
861
|
try {
|
|
744
|
-
const [sandboxRevision, setSandboxRevision] = createSignal(0);
|
|
745
862
|
const statusBadge = (ctx: TuiSlotContext) => {
|
|
746
863
|
sandboxRevision();
|
|
747
864
|
const directory = api.state.path.directory || process.cwd();
|
|
@@ -759,6 +876,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
759
876
|
|
|
760
877
|
const statusSlot: TuiSlotPlugin = {
|
|
761
878
|
slots: {
|
|
879
|
+
app: () => jsx(LandstripPane, {}),
|
|
762
880
|
home_prompt_right: (ctx) => statusBadge(ctx),
|
|
763
881
|
session_prompt: (_ctx, props) => renderSessionPrompt(props),
|
|
764
882
|
session_prompt_right: (ctx) => statusBadge(ctx),
|
|
@@ -767,7 +885,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
767
885
|
api.slots.register(statusSlot);
|
|
768
886
|
refreshSandboxStatus = () => setSandboxRevision((revision) => revision + 1);
|
|
769
887
|
} catch {
|
|
770
|
-
// Solid runtime unavailable on this host — skip the status badge.
|
|
888
|
+
// Solid runtime unavailable on this host — skip the pane and status badge.
|
|
771
889
|
}
|
|
772
890
|
|
|
773
891
|
// First-run onboarding: a single quiet pointer to the default-strict policy
|
|
@@ -777,7 +895,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
777
895
|
api.kv.set('onboarded', true);
|
|
778
896
|
api.ui.toast({
|
|
779
897
|
title: 'Sandbox active',
|
|
780
|
-
message: 'Landstrip sandbox is on. Run /
|
|
898
|
+
message: 'Landstrip sandbox is on. Run /landstrip to inspect it.',
|
|
781
899
|
variant: 'info',
|
|
782
900
|
duration: 8000,
|
|
783
901
|
});
|