opencode-landstrip 0.18.34 → 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 +23 -70
- package/index.ts +56 -467
- package/package.json +2 -2
- package/shared.ts +100 -37
- package/tui.ts +290 -154
- package/LICENSE +0 -201
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
|
},
|