@landstrip/landstrip 0.18.33 → 0.18.35
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/shared.d.ts +47 -0
- package/lib/shared.js +219 -0
- package/package.json +11 -7
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/shared.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
LandstripControlResponse,
|
|
5
|
+
LandstripFilesystemTrap,
|
|
6
|
+
LandstripNetworkTrap,
|
|
7
|
+
LandstripTrap,
|
|
8
|
+
} from './index.js';
|
|
9
|
+
|
|
10
|
+
export type LandstripDenialTrap = LandstripFilesystemTrap | LandstripNetworkTrap;
|
|
11
|
+
|
|
12
|
+
export function isRecord(value: unknown): value is Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
export function expandHomePath(path: string): string;
|
|
15
|
+
|
|
16
|
+
export function pathUnderDirectory(filePath: string, dir: string): boolean;
|
|
17
|
+
|
|
18
|
+
export function sessionAllows(prefixes: Set<string>, filePath: string): boolean;
|
|
19
|
+
|
|
20
|
+
export function sessionScopeFor(filePath: string, baseDirectory: string): string;
|
|
21
|
+
|
|
22
|
+
export function domainMatchesPattern(domain: string, pattern: string): boolean;
|
|
23
|
+
|
|
24
|
+
export function domainMatchesAny(domain: string, patterns: string[]): boolean;
|
|
25
|
+
|
|
26
|
+
export function allowsAllDomains(allowedDomains: string[]): boolean;
|
|
27
|
+
|
|
28
|
+
export function decodeLandstripTrap(value: unknown): LandstripTrap | null;
|
|
29
|
+
|
|
30
|
+
export function parseTrapLine(line: string): LandstripTrap | null;
|
|
31
|
+
|
|
32
|
+
export function parseLandstripTraps(output: string): LandstripTrap[];
|
|
33
|
+
|
|
34
|
+
export function isFilesystemTrap(trap: LandstripTrap): trap is LandstripFilesystemTrap;
|
|
35
|
+
|
|
36
|
+
export function isDenialTrap(trap: LandstripTrap): trap is LandstripDenialTrap;
|
|
37
|
+
|
|
38
|
+
export function isQueryTrap(trap: LandstripTrap): trap is LandstripDenialTrap;
|
|
39
|
+
|
|
40
|
+
export function formatLandstripTrap(trap: LandstripTrap): string;
|
|
41
|
+
|
|
42
|
+
export function formatLandstripTraps(traps: LandstripTrap[]): string;
|
|
43
|
+
|
|
44
|
+
export function controlResponseLine(
|
|
45
|
+
queryId: string,
|
|
46
|
+
action: LandstripControlResponse['action'],
|
|
47
|
+
): string;
|
package/lib/shared.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { realpathSync } = require('node:fs');
|
|
5
|
+
const { homedir } = require('node:os');
|
|
6
|
+
const { dirname, isAbsolute, join, relative, sep } = require('node:path');
|
|
7
|
+
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function expandHomePath(value) {
|
|
13
|
+
if (value === '~' || value === '$HOME') return homedir();
|
|
14
|
+
if (value.startsWith('~/')) return join(homedir(), value.slice(2));
|
|
15
|
+
if (value.startsWith('$HOME/')) return join(homedir(), value.slice(6));
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pathUnderDirectory(filePath, dir) {
|
|
20
|
+
const child = relative(dir, filePath);
|
|
21
|
+
return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sessionAllows(prefixes, filePath) {
|
|
25
|
+
for (const prefix of prefixes) {
|
|
26
|
+
if (pathUnderDirectory(filePath, prefix)) return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The broadest ancestor worth approving in one action: the immediate child of
|
|
32
|
+
// `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
|
|
33
|
+
// for paths under it, otherwise the containing directory. A file sitting
|
|
34
|
+
// directly on a boundary falls back to the exact file so nothing widens
|
|
35
|
+
// silently.
|
|
36
|
+
function sessionScopeFor(filePath, baseDirectory) {
|
|
37
|
+
const dir = dirname(filePath);
|
|
38
|
+
const homeBoundaries = new Set([homedir()]);
|
|
39
|
+
try {
|
|
40
|
+
homeBoundaries.add(realpathSync.native(homedir()));
|
|
41
|
+
} catch {
|
|
42
|
+
// $HOME not resolvable — fall back to the raw value only.
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const boundary of homeBoundaries) {
|
|
46
|
+
if (pathUnderDirectory(dir, boundary)) {
|
|
47
|
+
const first = relative(boundary, dir).split(sep)[0];
|
|
48
|
+
if (!first) return filePath;
|
|
49
|
+
return join(boundary, first);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const projectBoundaries = new Set([baseDirectory]);
|
|
54
|
+
try {
|
|
55
|
+
projectBoundaries.add(realpathSync.native(baseDirectory));
|
|
56
|
+
} catch {
|
|
57
|
+
// Project directory not resolvable — fall back to the raw value only.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const boundary of projectBoundaries) {
|
|
61
|
+
if (pathUnderDirectory(dir, boundary)) return boundary;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return dir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function domainMatchesPattern(domain, pattern) {
|
|
68
|
+
// A trailing dot ("pastebin.com.") is the same host to DNS but would slip
|
|
69
|
+
// past a literal deny entry; strip it from both sides before matching.
|
|
70
|
+
const normalizedDomain = domain.toLowerCase().replace(/\.+$/, '');
|
|
71
|
+
const normalizedPattern = pattern.toLowerCase().replace(/\.+$/, '');
|
|
72
|
+
|
|
73
|
+
if (normalizedPattern === '*') return true;
|
|
74
|
+
if (normalizedPattern.startsWith('*.')) {
|
|
75
|
+
const base = normalizedPattern.slice(2);
|
|
76
|
+
return normalizedDomain === base || normalizedDomain.endsWith(`.${base}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return normalizedDomain === normalizedPattern;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function domainMatchesAny(domain, patterns) {
|
|
83
|
+
return patterns.some((pattern) => domainMatchesPattern(domain, pattern));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function allowsAllDomains(allowedDomains) {
|
|
87
|
+
return allowedDomains.includes('*');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// landstrip emits each trap as a flat JSON record tagged by a `kind`
|
|
91
|
+
// discriminant (`filesystem`, `network`, `launch`, `usage`, `internal`)
|
|
92
|
+
// alongside a stable `code` and variant-specific fields. The declarations it
|
|
93
|
+
// ships are erased at compile time, so validate the fields callers read
|
|
94
|
+
// before trusting a decoded line. `state` is deliberately not validated: a
|
|
95
|
+
// missing or unknown state degrades to "informational", the safe direction.
|
|
96
|
+
// `query_id` must be a string — landstrip < 0.17 sent a number, and a
|
|
97
|
+
// numeric id fails its own deserializer when echoed back.
|
|
98
|
+
const LANDSTRIP_OPERATIONS = new Set(['read', 'write']);
|
|
99
|
+
|
|
100
|
+
function isLandstripTrap(value) {
|
|
101
|
+
if (!isRecord(value)) return false;
|
|
102
|
+
|
|
103
|
+
switch (value.kind) {
|
|
104
|
+
case 'filesystem':
|
|
105
|
+
return (
|
|
106
|
+
LANDSTRIP_OPERATIONS.has(value.operation) &&
|
|
107
|
+
typeof value.path === 'string' &&
|
|
108
|
+
typeof value.query_id === 'string'
|
|
109
|
+
);
|
|
110
|
+
case 'network':
|
|
111
|
+
return (
|
|
112
|
+
typeof value.operation === 'string' &&
|
|
113
|
+
typeof value.target === 'string' &&
|
|
114
|
+
typeof value.query_id === 'string'
|
|
115
|
+
);
|
|
116
|
+
case 'launch':
|
|
117
|
+
return typeof value.program === 'string' && typeof value.message === 'string';
|
|
118
|
+
case 'usage':
|
|
119
|
+
return typeof value.message === 'string';
|
|
120
|
+
case 'internal':
|
|
121
|
+
return typeof value.code === 'string' && typeof value.message === 'string';
|
|
122
|
+
default:
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function decodeLandstripTrap(value) {
|
|
128
|
+
return isLandstripTrap(value) ? value : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseTrapLine(line) {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(line);
|
|
134
|
+
return isLandstripTrap(parsed) ? parsed : null;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseLandstripTraps(output) {
|
|
141
|
+
const traps = [];
|
|
142
|
+
|
|
143
|
+
for (const line of output.split('\n')) {
|
|
144
|
+
const trimmed = line.trim();
|
|
145
|
+
if (trimmed.length === 0 || trimmed[0] !== '{') continue;
|
|
146
|
+
const trap = parseTrapLine(trimmed);
|
|
147
|
+
if (trap) traps.push(trap);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return traps;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isFilesystemTrap(trap) {
|
|
154
|
+
return trap.kind === 'filesystem';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// filesystem and network traps report an access the policy denied; launch,
|
|
158
|
+
// usage and internal traps report that landstrip itself failed.
|
|
159
|
+
function isDenialTrap(trap) {
|
|
160
|
+
return trap.kind === 'filesystem' || trap.kind === 'network';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A `state: "query"` trap suspends the child's syscall until the host answers
|
|
164
|
+
// it on the trap socket. An `info` trap is terminal.
|
|
165
|
+
function isQueryTrap(trap) {
|
|
166
|
+
return isDenialTrap(trap) && trap.state === 'query';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatLandstripTrap(trap) {
|
|
170
|
+
switch (trap.kind) {
|
|
171
|
+
case 'filesystem':
|
|
172
|
+
return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
|
|
173
|
+
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
174
|
+
}`;
|
|
175
|
+
case 'network':
|
|
176
|
+
return `landstrip: network ${trap.operation} denied (${trap.target})${
|
|
177
|
+
trap.mechanism ? ` [${trap.mechanism}]` : ''
|
|
178
|
+
}`;
|
|
179
|
+
case 'launch':
|
|
180
|
+
return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
|
|
181
|
+
case 'usage':
|
|
182
|
+
return `landstrip: usage error: ${trap.message}`;
|
|
183
|
+
case 'internal': {
|
|
184
|
+
const mechanism = trap.mechanism ? ` [${trap.mechanism}]` : '';
|
|
185
|
+
return `landstrip: ${trap.code}${mechanism}: ${trap.message}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function formatLandstripTraps(traps) {
|
|
191
|
+
return traps.map(formatLandstripTrap).join('\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The broker matches an answer to its query by the exact decimal `query_id`
|
|
195
|
+
// string the trap carried. A numeric id fails its deserializer, the line is
|
|
196
|
+
// dropped, and the child's syscall stays suspended.
|
|
197
|
+
function controlResponseLine(queryId, action) {
|
|
198
|
+
return JSON.stringify({ query_id: queryId, action }) + '\n';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
isRecord,
|
|
203
|
+
expandHomePath,
|
|
204
|
+
pathUnderDirectory,
|
|
205
|
+
sessionAllows,
|
|
206
|
+
sessionScopeFor,
|
|
207
|
+
domainMatchesPattern,
|
|
208
|
+
domainMatchesAny,
|
|
209
|
+
allowsAllDomains,
|
|
210
|
+
decodeLandstripTrap,
|
|
211
|
+
parseTrapLine,
|
|
212
|
+
parseLandstripTraps,
|
|
213
|
+
isFilesystemTrap,
|
|
214
|
+
isDenialTrap,
|
|
215
|
+
isQueryTrap,
|
|
216
|
+
formatLandstripTrap,
|
|
217
|
+
formatLandstripTraps,
|
|
218
|
+
controlResponseLine,
|
|
219
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@landstrip/landstrip",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.35",
|
|
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",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
".": {
|
|
18
18
|
"types": "./lib/index.d.ts",
|
|
19
19
|
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./shared": {
|
|
22
|
+
"types": "./lib/shared.d.ts",
|
|
23
|
+
"default": "./lib/shared.js"
|
|
20
24
|
}
|
|
21
25
|
},
|
|
22
26
|
"bin": {
|
|
@@ -30,12 +34,12 @@
|
|
|
30
34
|
"LICENSE-LGPL-2.1"
|
|
31
35
|
],
|
|
32
36
|
"optionalDependencies": {
|
|
33
|
-
"@landstrip/landstrip-darwin-arm64": "0.18.
|
|
34
|
-
"@landstrip/landstrip-darwin-x64": "0.18.
|
|
35
|
-
"@landstrip/landstrip-linux-x64": "0.18.
|
|
36
|
-
"@landstrip/landstrip-linux-arm64": "0.18.
|
|
37
|
-
"@landstrip/landstrip-win32-x64": "0.18.
|
|
38
|
-
"@landstrip/landstrip-win32-arm64": "0.18.
|
|
37
|
+
"@landstrip/landstrip-darwin-arm64": "0.18.35",
|
|
38
|
+
"@landstrip/landstrip-darwin-x64": "0.18.35",
|
|
39
|
+
"@landstrip/landstrip-linux-x64": "0.18.35",
|
|
40
|
+
"@landstrip/landstrip-linux-arm64": "0.18.35",
|
|
41
|
+
"@landstrip/landstrip-win32-x64": "0.18.35",
|
|
42
|
+
"@landstrip/landstrip-win32-arm64": "0.18.35"
|
|
39
43
|
},
|
|
40
44
|
"publishConfig": {
|
|
41
45
|
"access": "public"
|