@landstrip/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 +19 -53
- package/lib/proxy.d.ts +24 -0
- package/lib/proxy.js +304 -0
- package/lib/shared.d.ts +10 -0
- package/lib/shared.js +82 -2
- package/package.json +11 -14
package/README.md
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
<!-- SPDX-License-Identifier: LGPL-2.1-or-later -->
|
|
2
|
-
<!-- Copyright (C) Jarkko Sakkinen 2026 -->
|
|
3
|
-
|
|
4
1
|
# landstrip
|
|
5
2
|
|
|
6
3
|
`landstrip` runs commands in an OS-level sandbox using Landlock on Linux,
|
|
7
|
-
Seatbelt on macOS, and AppContainer or restricted users on Windows.
|
|
8
|
-
the supported subset of the Anthropic Sandbox Runtime format.
|
|
4
|
+
Seatbelt on macOS, and AppContainer or restricted users on Windows.
|
|
9
5
|
|
|
10
|
-
##
|
|
6
|
+
## Install
|
|
11
7
|
|
|
12
8
|
```sh
|
|
13
9
|
npm install --save-dev @landstrip/landstrip
|
|
@@ -17,7 +13,7 @@ npx landstrip run -p policy.json -- cargo test
|
|
|
17
13
|
The npm package installs a Node.js wrapper and a native binary for the current
|
|
18
14
|
platform.
|
|
19
15
|
|
|
20
|
-
##
|
|
16
|
+
## Usage
|
|
21
17
|
|
|
22
18
|
```sh
|
|
23
19
|
landstrip run -p policy.json -- cargo test
|
|
@@ -26,21 +22,17 @@ landstrip policy resolve -p policy.json
|
|
|
26
22
|
landstrip doctor
|
|
27
23
|
```
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
[manual page](man/man1/landstrip.1).
|
|
32
|
-
|
|
33
|
-
### Agent extensions
|
|
25
|
+
Run `landstrip --help` or see [landstrip(1)](man/man1/landstrip.1) for the full
|
|
26
|
+
command reference.
|
|
34
27
|
|
|
35
|
-
|
|
36
|
-
pi install npm:pi-landstrip
|
|
37
|
-
opencode plugin install opencode-landstrip
|
|
38
|
-
```
|
|
28
|
+
## Agent extensions
|
|
39
29
|
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
| Integration | Package | Documentation |
|
|
31
|
+
| ----------- | ---------------------- | -------------------------------------------------------------------- |
|
|
32
|
+
| OpenCode | `opencode-landstrip` | [OpenCode](packages/opencode-landstrip/README.md) |
|
|
33
|
+
| Pi | `pi-landstrip` | [Pi](packages/pi-landstrip/README.md) |
|
|
42
34
|
|
|
43
|
-
## Policy
|
|
35
|
+
## Policy
|
|
44
36
|
|
|
45
37
|
```json
|
|
46
38
|
{
|
|
@@ -52,49 +44,23 @@ See [pi-landstrip](packages/pi-landstrip/README.md) and
|
|
|
52
44
|
},
|
|
53
45
|
"network": {
|
|
54
46
|
"allowNetwork": false,
|
|
55
|
-
"
|
|
56
|
-
"allowLocalBinding": false,
|
|
57
|
-
"allowUnixSockets": []
|
|
47
|
+
"allowLocalBinding": false
|
|
58
48
|
}
|
|
59
49
|
}
|
|
60
50
|
```
|
|
61
51
|
|
|
62
|
-
Pass policies with `-p, --policy <FILE
|
|
63
|
-
|
|
52
|
+
Pass policies with `-p, --policy <FILE>`. Later policies merge over earlier
|
|
53
|
+
ones. Platform behavior and limits are documented in the manual.
|
|
64
54
|
|
|
65
55
|
## Development
|
|
66
56
|
|
|
67
|
-
### Package
|
|
68
|
-
|
|
69
|
-
Artifacts land in `npm/*/bin` and `artifacts/`. Staging writes platform tarballs
|
|
70
|
-
and matching `<file>.sha256` sidecars under `artifacts/`.
|
|
71
|
-
|
|
72
|
-
Non-host targets use [`cargo-zigbuild`](https://github.com/rust-cross/cargo-zigbuild)
|
|
73
|
-
(Zig as linker). Requires `cargo-zigbuild`, `zig`, and the matching `rustup`
|
|
74
|
-
targets (`x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`,
|
|
75
|
-
`x86_64-pc-windows-gnu`, `aarch64-pc-windows-gnullvm`, `aarch64-apple-darwin`,
|
|
76
|
-
`x86_64-apple-darwin`). Host-native triples use plain `cargo`. Darwin links
|
|
77
|
-
`libsandbox`; from Linux set `SDKROOT` to a macOS SDK (Apple SDK; not
|
|
78
|
-
redistributed).
|
|
79
|
-
|
|
80
|
-
```sh
|
|
81
|
-
make package
|
|
82
|
-
make package PLATFORMS='linux-x64 win32-x64'
|
|
83
|
-
PACKAGE_STRICT=1 make package
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
`make publish` uploads each GitHub release tarball with its `<file>.sha256`
|
|
87
|
-
sidecar. Verify a download with:
|
|
88
|
-
|
|
89
57
|
```sh
|
|
90
|
-
|
|
58
|
+
make ci
|
|
91
59
|
```
|
|
92
60
|
|
|
93
|
-
##
|
|
61
|
+
## Licensing
|
|
94
62
|
|
|
95
|
-
`landstrip`
|
|
96
|
-
version 2.1 or later (LGPL-2.1+). See [LICENSE-LGPL-2.1](LICENSE-LGPL-2.1).
|
|
63
|
+
`landstrip` uses [LGPL-2.1+](LICENSE-LGPL-2.1).
|
|
97
64
|
|
|
98
|
-
The JavaScript
|
|
99
|
-
[
|
|
100
|
-
package is available from the matching repository tag.
|
|
65
|
+
The JavaScript wrapper and agent extensions use
|
|
66
|
+
[Apache 2.0](LICENSE-APACHE-2.0).
|
package/lib/proxy.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
export interface ProxyPortRange {
|
|
4
|
+
low: number;
|
|
5
|
+
high: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FilterProxyOptions {
|
|
9
|
+
/** Gate for every proxied domain; may prompt interactively. */
|
|
10
|
+
isDomainAllowed(domain: string): boolean | Promise<boolean>;
|
|
11
|
+
/** Exact `Proxy-Authorization` header value required from clients. */
|
|
12
|
+
authorization?: string;
|
|
13
|
+
/** Inclusive listen port range; defaults to an ephemeral port. */
|
|
14
|
+
portRange?: ProxyPortRange;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FilterProxyHandle {
|
|
18
|
+
port: number;
|
|
19
|
+
stop(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isPublicProxyAddress(address: string, family?: number): boolean;
|
|
23
|
+
|
|
24
|
+
export function startFilterProxy(options: FilterProxyOptions): Promise<FilterProxyHandle>;
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { lookup } = require('node:dns/promises');
|
|
5
|
+
const { BlockList, connect, createServer, isIP } = require('node:net');
|
|
6
|
+
|
|
7
|
+
const prohibitedProxyAddresses = new BlockList();
|
|
8
|
+
|
|
9
|
+
for (const [network, prefix] of [
|
|
10
|
+
['0.0.0.0', 8],
|
|
11
|
+
['10.0.0.0', 8],
|
|
12
|
+
['100.64.0.0', 10],
|
|
13
|
+
['127.0.0.0', 8],
|
|
14
|
+
['169.254.0.0', 16],
|
|
15
|
+
['172.16.0.0', 12],
|
|
16
|
+
['192.0.0.0', 24],
|
|
17
|
+
['192.168.0.0', 16],
|
|
18
|
+
['198.18.0.0', 15],
|
|
19
|
+
['224.0.0.0', 4],
|
|
20
|
+
['240.0.0.0', 4],
|
|
21
|
+
]) {
|
|
22
|
+
prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv4');
|
|
23
|
+
}
|
|
24
|
+
for (const [network, prefix] of [
|
|
25
|
+
['::', 128],
|
|
26
|
+
['::1', 128],
|
|
27
|
+
['fc00::', 7],
|
|
28
|
+
['fe80::', 10],
|
|
29
|
+
['ff00::', 8],
|
|
30
|
+
]) {
|
|
31
|
+
prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv6');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseProxyPort(value, defaultPort) {
|
|
35
|
+
const rawPort = value ?? String(defaultPort);
|
|
36
|
+
if (!/^\d+$/.test(rawPort)) return null;
|
|
37
|
+
|
|
38
|
+
const port = Number(rawPort);
|
|
39
|
+
return port >= 1 && port <= 65535 ? port : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function splitHostPort(target, defaultPort) {
|
|
43
|
+
const bracketMatch = target.match(/^\[([^\]]+)\](?::(.*))?$/);
|
|
44
|
+
const host = bracketMatch?.[1];
|
|
45
|
+
if (host) {
|
|
46
|
+
const port = parseProxyPort(bracketMatch[2], defaultPort);
|
|
47
|
+
return port === null ? null : { host, port };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lastColon = target.lastIndexOf(':');
|
|
51
|
+
if (lastColon > -1 && target.indexOf(':') === lastColon) {
|
|
52
|
+
const port = parseProxyPort(target.slice(lastColon + 1), defaultPort);
|
|
53
|
+
return port === null ? null : { host: target.slice(0, lastColon), port };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { host: target, port: defaultPort };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function denyProxyRequest(client, status = '403 Forbidden') {
|
|
60
|
+
client.write(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`);
|
|
61
|
+
client.end();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPublicProxyAddress(address, family = isIP(address)) {
|
|
65
|
+
if (family === 4) return !prohibitedProxyAddresses.check(address, 'ipv4');
|
|
66
|
+
if (family === 6) return !prohibitedProxyAddresses.check(address, 'ipv6');
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveProxyEndpoint(host) {
|
|
71
|
+
const literalFamily = isIP(host);
|
|
72
|
+
if (literalFamily === 4 || literalFamily === 6) {
|
|
73
|
+
if (!isPublicProxyAddress(host, literalFamily)) {
|
|
74
|
+
throw new Error(`Proxy destination is not public: ${host}`);
|
|
75
|
+
}
|
|
76
|
+
return { address: host, family: literalFamily };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const addresses = await lookup(host, { all: true, verbatim: true });
|
|
80
|
+
if (
|
|
81
|
+
addresses.length === 0 ||
|
|
82
|
+
addresses.some(({ address, family }) => !isPublicProxyAddress(address, family))
|
|
83
|
+
) {
|
|
84
|
+
throw new Error(`Proxy destination resolves to a non-public address: ${host}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const endpoint = addresses[0];
|
|
88
|
+
if (endpoint.family !== 4 && endpoint.family !== 6) {
|
|
89
|
+
throw new Error(`Proxy could not resolve destination: ${host}`);
|
|
90
|
+
}
|
|
91
|
+
return { address: endpoint.address, family: endpoint.family };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pipeSockets(client, upstream, initialData) {
|
|
95
|
+
upstream.on('error', () => client.destroy());
|
|
96
|
+
client.on('error', () => upstream.destroy());
|
|
97
|
+
|
|
98
|
+
if (initialData?.length) upstream.write(initialData);
|
|
99
|
+
|
|
100
|
+
client.pipe(upstream);
|
|
101
|
+
upstream.pipe(client);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function startFilterProxy(options) {
|
|
105
|
+
const { isDomainAllowed, authorization, portRange } = options;
|
|
106
|
+
const sockets = new Set();
|
|
107
|
+
|
|
108
|
+
// Track the upstream socket so stop() tears it down, and abandon a still-
|
|
109
|
+
// connecting upstream when its client goes away — otherwise a connect to a
|
|
110
|
+
// black-holed host lingers in SYN-retry (~2 min), leaking an fd per request.
|
|
111
|
+
function trackUpstream(upstream, client, settled) {
|
|
112
|
+
sockets.add(upstream);
|
|
113
|
+
upstream.once('close', () => sockets.delete(upstream));
|
|
114
|
+
client.once('close', () => {
|
|
115
|
+
if (!settled()) upstream.destroy();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function handleConnect(client, target, rest) {
|
|
120
|
+
const endpoint = splitHostPort(target, 443);
|
|
121
|
+
if (!endpoint) {
|
|
122
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!(await isDomainAllowed(endpoint.host))) {
|
|
127
|
+
denyProxyRequest(client);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const resolved = await resolveProxyEndpoint(endpoint.host);
|
|
132
|
+
let settled = false;
|
|
133
|
+
const upstream = connect(
|
|
134
|
+
{ host: resolved.address, port: endpoint.port, family: resolved.family },
|
|
135
|
+
() => {
|
|
136
|
+
settled = true;
|
|
137
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
138
|
+
pipeSockets(client, upstream, rest);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
trackUpstream(upstream, client, () => settled);
|
|
142
|
+
upstream.once('error', () => {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
denyProxyRequest(client, '502 Bad Gateway');
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function handleHttp(client, headerText, rest) {
|
|
150
|
+
const lines = headerText.split(/\r?\n/);
|
|
151
|
+
const requestLine = lines[0];
|
|
152
|
+
if (!requestLine) {
|
|
153
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const [method, rawTarget, version] = requestLine.split(' ');
|
|
158
|
+
if (!method || !rawTarget || !version) {
|
|
159
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let url;
|
|
164
|
+
try {
|
|
165
|
+
url = new URL(rawTarget);
|
|
166
|
+
} catch {
|
|
167
|
+
const host = lines
|
|
168
|
+
.find((line) => line.toLowerCase().startsWith('host:'))
|
|
169
|
+
?.slice(5)
|
|
170
|
+
.trim();
|
|
171
|
+
if (!host) {
|
|
172
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
url = new URL(`http://${host}${rawTarget}`);
|
|
178
|
+
} catch {
|
|
179
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!(await isDomainAllowed(url.hostname))) {
|
|
185
|
+
denyProxyRequest(client);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const port = parseProxyPort(url.port || undefined, url.protocol === 'https:' ? 443 : 80);
|
|
190
|
+
if (port === null) {
|
|
191
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const path = `${url.pathname}${url.search}` || '/';
|
|
196
|
+
lines[0] = `${method} ${path} ${version}`;
|
|
197
|
+
|
|
198
|
+
const rewrittenHeader = lines
|
|
199
|
+
.filter(
|
|
200
|
+
(line) =>
|
|
201
|
+
!line.toLowerCase().startsWith('proxy-connection:') &&
|
|
202
|
+
!line.toLowerCase().startsWith('proxy-authorization:'),
|
|
203
|
+
)
|
|
204
|
+
.map((line) => (line.toLowerCase().startsWith('host:') ? `Host: ${url.host}` : line))
|
|
205
|
+
.join('\r\n');
|
|
206
|
+
const resolved = await resolveProxyEndpoint(url.hostname);
|
|
207
|
+
let settled = false;
|
|
208
|
+
const upstream = connect({ host: resolved.address, port, family: resolved.family }, () => {
|
|
209
|
+
settled = true;
|
|
210
|
+
upstream.write(`${rewrittenHeader}\r\n\r\n`);
|
|
211
|
+
pipeSockets(client, upstream, rest);
|
|
212
|
+
});
|
|
213
|
+
trackUpstream(upstream, client, () => settled);
|
|
214
|
+
upstream.once('error', () => {
|
|
215
|
+
if (settled) return;
|
|
216
|
+
settled = true;
|
|
217
|
+
denyProxyRequest(client, '502 Bad Gateway');
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function handleClient(client) {
|
|
222
|
+
sockets.add(client);
|
|
223
|
+
client.on('close', () => sockets.delete(client));
|
|
224
|
+
client.on('error', () => sockets.delete(client));
|
|
225
|
+
|
|
226
|
+
let buffered = Buffer.alloc(0);
|
|
227
|
+
|
|
228
|
+
client.on('data', (chunk) => {
|
|
229
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
230
|
+
const headerEnd = buffered.indexOf('\r\n\r\n');
|
|
231
|
+
if (headerEnd === -1) {
|
|
232
|
+
if (buffered.length > 65536) {
|
|
233
|
+
client.removeAllListeners('data');
|
|
234
|
+
client.pause();
|
|
235
|
+
denyProxyRequest(client, '431 Request Header Fields Too Large');
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
client.pause();
|
|
241
|
+
client.removeAllListeners('data');
|
|
242
|
+
|
|
243
|
+
const header = buffered.subarray(0, headerEnd).toString('utf-8');
|
|
244
|
+
const rest = buffered.subarray(headerEnd + 4);
|
|
245
|
+
if (authorization) {
|
|
246
|
+
const supplied = header
|
|
247
|
+
.split(/\r?\n/)
|
|
248
|
+
.find((line) => line.toLowerCase().startsWith('proxy-authorization:'))
|
|
249
|
+
?.slice('proxy-authorization:'.length)
|
|
250
|
+
.trim();
|
|
251
|
+
if (supplied !== authorization) {
|
|
252
|
+
denyProxyRequest(client, '407 Proxy Authentication Required');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const [method, target] = header.split(/\r?\n/, 1)[0].split(' ');
|
|
257
|
+
|
|
258
|
+
const task =
|
|
259
|
+
method?.toUpperCase() === 'CONNECT' && target
|
|
260
|
+
? handleConnect(client, target, rest)
|
|
261
|
+
: handleHttp(client, header, rest);
|
|
262
|
+
task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const server = createServer(handleClient);
|
|
267
|
+
let stopped = false;
|
|
268
|
+
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const listen = (port) => {
|
|
271
|
+
server.once('error', (error) => {
|
|
272
|
+
if (error.code === 'EADDRINUSE' && portRange && port < portRange.high) {
|
|
273
|
+
listen(port + 1);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
reject(error);
|
|
277
|
+
});
|
|
278
|
+
server.listen(port, '127.0.0.1', () => {
|
|
279
|
+
server.removeAllListeners('error');
|
|
280
|
+
const address = server.address();
|
|
281
|
+
|
|
282
|
+
resolve({
|
|
283
|
+
port: address.port,
|
|
284
|
+
stop: () =>
|
|
285
|
+
new Promise((done) => {
|
|
286
|
+
if (stopped) {
|
|
287
|
+
done();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
stopped = true;
|
|
291
|
+
for (const socket of sockets) socket.destroy();
|
|
292
|
+
server.close(() => done());
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
listen(portRange ? portRange.low : 0);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
isPublicProxyAddress,
|
|
303
|
+
startFilterProxy,
|
|
304
|
+
};
|
package/lib/shared.d.ts
CHANGED
|
@@ -13,6 +13,16 @@ export function isRecord(value: unknown): value is Record<string, unknown>;
|
|
|
13
13
|
|
|
14
14
|
export function expandHomePath(path: string): string;
|
|
15
15
|
|
|
16
|
+
export function expandPath(filePath: string, baseDirectory: string): string;
|
|
17
|
+
|
|
18
|
+
export function canonicalizePath(filePath: string, baseDirectory: string): string;
|
|
19
|
+
|
|
20
|
+
export function canonicalizeGlobPattern(pattern: string, baseDirectory: string): string;
|
|
21
|
+
|
|
22
|
+
export function normalizePathSeparators(path: string): string;
|
|
23
|
+
|
|
24
|
+
export function globToRegExp(globPattern: string): RegExp;
|
|
25
|
+
|
|
16
26
|
export function pathUnderDirectory(filePath: string, dir: string): boolean;
|
|
17
27
|
|
|
18
28
|
export function sessionAllows(prefixes: Set<string>, filePath: string): boolean;
|
package/lib/shared.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const { realpathSync } = require('node:fs');
|
|
4
|
+
const { lstatSync, readlinkSync, realpathSync } = require('node:fs');
|
|
5
5
|
const { homedir } = require('node:os');
|
|
6
|
-
const { dirname, isAbsolute, join, relative, sep } = require('node:path');
|
|
6
|
+
const { basename, dirname, isAbsolute, join, relative, resolve, sep } = require('node:path');
|
|
7
7
|
|
|
8
8
|
function isRecord(value) {
|
|
9
9
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -191,6 +191,81 @@ function formatLandstripTraps(traps) {
|
|
|
191
191
|
return traps.map(formatLandstripTrap).join('\n');
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// Relative entries (notably ".") resolve against the caller-supplied base
|
|
195
|
+
// directory — the command's working directory that landstrip itself uses as
|
|
196
|
+
// its policy base — never the host process's own cwd.
|
|
197
|
+
function expandPath(filePath, baseDirectory) {
|
|
198
|
+
return resolve(baseDirectory, expandHomePath(filePath));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function canonicalizePath(filePath, baseDirectory, seen = new Set()) {
|
|
202
|
+
const abs = expandPath(filePath, baseDirectory);
|
|
203
|
+
const missing = [];
|
|
204
|
+
let existing = abs;
|
|
205
|
+
|
|
206
|
+
for (;;) {
|
|
207
|
+
try {
|
|
208
|
+
const stat = lstatSync(existing);
|
|
209
|
+
if (stat.isSymbolicLink()) {
|
|
210
|
+
if (seen.has(existing)) return abs;
|
|
211
|
+
seen.add(existing);
|
|
212
|
+
const target = resolve(dirname(existing), readlinkSync(existing), ...missing);
|
|
213
|
+
return canonicalizePath(target, baseDirectory, seen);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
} catch {
|
|
217
|
+
const parent = dirname(existing);
|
|
218
|
+
if (parent === existing) return abs;
|
|
219
|
+
missing.unshift(basename(existing));
|
|
220
|
+
existing = parent;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
return resolve(realpathSync.native(existing), ...missing);
|
|
226
|
+
} catch {
|
|
227
|
+
return abs;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function canonicalizeGlobPattern(pattern, baseDirectory) {
|
|
232
|
+
const expanded = expandPath(pattern, baseDirectory);
|
|
233
|
+
const wildcard = expanded.indexOf('*');
|
|
234
|
+
if (wildcard < 0) return canonicalizePath(expanded, baseDirectory);
|
|
235
|
+
|
|
236
|
+
const prefix = expanded.slice(0, wildcard);
|
|
237
|
+
const base = prefix.endsWith(sep) ? prefix : dirname(prefix);
|
|
238
|
+
const suffixOffset = base.endsWith(sep) ? base.length - 1 : base.length;
|
|
239
|
+
const canonicalBase = canonicalizePath(base, baseDirectory);
|
|
240
|
+
const suffix = expanded.slice(suffixOffset);
|
|
241
|
+
return canonicalBase.endsWith(sep) && suffix.startsWith(sep)
|
|
242
|
+
? `${canonicalBase}${suffix.slice(1)}`
|
|
243
|
+
: `${canonicalBase}${suffix}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizePathSeparators(path) {
|
|
247
|
+
return process.platform === 'win32' ? path.replaceAll('\\', '/') : path;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const globRegExpCache = new Map();
|
|
251
|
+
|
|
252
|
+
// Translates an absolute glob pattern to a regular expression using standard
|
|
253
|
+
// path semantics: `**` crosses directory boundaries (and `**/` may match zero
|
|
254
|
+
// segments), while a single `*` is confined to one path segment.
|
|
255
|
+
function globToRegExp(globPattern) {
|
|
256
|
+
const cached = globRegExpCache.get(globPattern);
|
|
257
|
+
if (cached) return cached;
|
|
258
|
+
|
|
259
|
+
const escaped = globPattern
|
|
260
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
261
|
+
.replace(/\*\*\/|\*\*|\*/g, (token) =>
|
|
262
|
+
token === '**/' ? '(?:.*/)?' : token === '**' ? '.*' : '[^/]*',
|
|
263
|
+
);
|
|
264
|
+
const result = new RegExp(`^${escaped}$`);
|
|
265
|
+
globRegExpCache.set(globPattern, result);
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
194
269
|
// The broker matches an answer to its query by the exact decimal `query_id`
|
|
195
270
|
// string the trap carried. A numeric id fails its deserializer, the line is
|
|
196
271
|
// dropped, and the child's syscall stays suspended.
|
|
@@ -201,6 +276,11 @@ function controlResponseLine(queryId, action) {
|
|
|
201
276
|
module.exports = {
|
|
202
277
|
isRecord,
|
|
203
278
|
expandHomePath,
|
|
279
|
+
expandPath,
|
|
280
|
+
canonicalizePath,
|
|
281
|
+
canonicalizeGlobPattern,
|
|
282
|
+
normalizePathSeparators,
|
|
283
|
+
globToRegExp,
|
|
204
284
|
pathUnderDirectory,
|
|
205
285
|
sessionAllows,
|
|
206
286
|
sessionScopeFor,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@landstrip/landstrip",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.36",
|
|
4
4
|
"description": "Sandbox runner using Landlock, Seatbelt, and AppContainer",
|
|
5
5
|
"license": "Apache-2.0 AND LGPL-2.1-or-later",
|
|
6
6
|
"homepage": "https://github.com/landstrip/landstrip#readme",
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"./shared": {
|
|
22
22
|
"types": "./lib/shared.d.ts",
|
|
23
23
|
"default": "./lib/shared.js"
|
|
24
|
+
},
|
|
25
|
+
"./proxy": {
|
|
26
|
+
"types": "./lib/proxy.d.ts",
|
|
27
|
+
"default": "./lib/proxy.js"
|
|
24
28
|
}
|
|
25
29
|
},
|
|
26
30
|
"bin": {
|
|
@@ -34,24 +38,17 @@
|
|
|
34
38
|
"LICENSE-LGPL-2.1"
|
|
35
39
|
],
|
|
36
40
|
"optionalDependencies": {
|
|
37
|
-
"@landstrip/landstrip-darwin-arm64": "0.18.
|
|
38
|
-
"@landstrip/landstrip-darwin-x64": "0.18.
|
|
39
|
-
"@landstrip/landstrip-linux-x64": "0.18.
|
|
40
|
-
"@landstrip/landstrip-linux-arm64": "0.18.
|
|
41
|
-
"@landstrip/landstrip-win32-x64": "0.18.
|
|
42
|
-
"@landstrip/landstrip-win32-arm64": "0.18.
|
|
41
|
+
"@landstrip/landstrip-darwin-arm64": "0.18.36",
|
|
42
|
+
"@landstrip/landstrip-darwin-x64": "0.18.36",
|
|
43
|
+
"@landstrip/landstrip-linux-x64": "0.18.36",
|
|
44
|
+
"@landstrip/landstrip-linux-arm64": "0.18.36",
|
|
45
|
+
"@landstrip/landstrip-win32-x64": "0.18.36",
|
|
46
|
+
"@landstrip/landstrip-win32-arm64": "0.18.36"
|
|
43
47
|
},
|
|
44
48
|
"publishConfig": {
|
|
45
49
|
"access": "public"
|
|
46
50
|
},
|
|
47
51
|
"engines": {
|
|
48
52
|
"node": ">=18"
|
|
49
|
-
},
|
|
50
|
-
"workspaces": [
|
|
51
|
-
"packages/*"
|
|
52
|
-
],
|
|
53
|
-
"scripts": {
|
|
54
|
-
"ci:extensions": "bash scripts/test-extensions.sh",
|
|
55
|
-
"ci:extensions:local": "bash scripts/test-extensions.sh --local-root"
|
|
56
53
|
}
|
|
57
54
|
}
|