opencode-landstrip 0.18.42 → 0.19.0
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 +27 -34
- package/index.ts +31 -16
- package/package.json +2 -2
- package/shared.ts +4 -86
- package/tui.ts +4 -0
package/README.md
CHANGED
|
@@ -1,51 +1,44 @@
|
|
|
1
1
|
# opencode-landstrip
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Landstrip sandboxing for AI `bash` calls in OpenCode ≥1.17.7.
|
|
4
4
|
|
|
5
|
-
## Install
|
|
5
|
+
## Install and use
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
#
|
|
9
|
-
opencode plugin install opencode-landstrip
|
|
10
|
-
|
|
11
|
-
# Global
|
|
12
|
-
opencode plugin install opencode-landstrip --global
|
|
8
|
+
opencode plugin install opencode-landstrip # project
|
|
9
|
+
opencode plugin install opencode-landstrip --global # global
|
|
13
10
|
```
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
## Permissions
|
|
12
|
+
Installation configures server and TUI plugins. `/landstrip` opens the management
|
|
13
|
+
pane; Pi's agent/task subcommands are unavailable.
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
filesystem and network access. Agent approval never bypasses a sandbox hard
|
|
21
|
-
denial, and requests without a live presenter are denied.
|
|
15
|
+
## Permissions and configuration
|
|
22
16
|
|
|
23
|
-
OpenCode
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
OpenCode authorizes tool dispatch; Landstrip enforces filesystem/network access.
|
|
18
|
+
Approval never bypasses hard denials. Interactive requests without a live
|
|
19
|
+
presenter remain denied. Only AI `bash` receives OS isolation: OpenCode's plugin
|
|
20
|
+
API cannot replace direct user shell commands.
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
Policy precedence (persistent approvals update the project/global files):
|
|
29
23
|
|
|
30
|
-
|
|
24
|
+
1. Bundled [`sandbox.json`](sandbox.json).
|
|
25
|
+
2. `~/.config/opencode/sandbox.json`.
|
|
26
|
+
3. `.opencode/sandbox.json`.
|
|
27
|
+
4. Plugin options.
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
4. plugin options.
|
|
29
|
+
Arrays combine; later scalar values win. Defaults allow project writes, deny
|
|
30
|
+
sensitive-file writes, and block network access. Enabled sandboxing fails closed
|
|
31
|
+
on unusable binaries/platforms; `enabled: false` explicitly permits unsandboxed Bash.
|
|
36
32
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
defaults.
|
|
33
|
+
See [landstrip(1)](../landstrip/man/man1/landstrip.1) for native CLI/policy semantics.
|
|
34
|
+
Runtime seccomp traps/query approval are Linux-only. Native macOS `--trap` reports
|
|
35
|
+
setup/launch errors only and closes on successful exec—not runtime violations.
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
closed. Set `enabled` to `false` explicitly to allow unsandboxed AI Bash.
|
|
37
|
+
## Development
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
From this package: `npm ci`, then `npm run all` (format, lint, typecheck, tests).
|
|
40
|
+
Read-only formatting check: `npm run ci:fmt`.
|
|
47
41
|
|
|
48
|
-
##
|
|
42
|
+
## License
|
|
49
43
|
|
|
50
|
-
|
|
51
|
-
for more information.
|
|
44
|
+
Apache-2.0; see [LICENSE](LICENSE).
|
package/index.ts
CHANGED
|
@@ -78,8 +78,8 @@ function normalizePathForMatch(filePath: string): string {
|
|
|
78
78
|
return process.platform === 'win32' ? normalizePathSeparators(filePath).toLowerCase() : filePath;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Component count of an absolute path; "/" is 0.
|
|
82
|
-
// matching
|
|
81
|
+
// Component count of an absolute path; "/" is 0. Read rules use it to rank
|
|
82
|
+
// matching allow and deny patterns by specificity.
|
|
83
83
|
function pathDepth(absolutePath: string): number {
|
|
84
84
|
return absolutePath.split('/').filter((segment) => segment.length > 0).length;
|
|
85
85
|
}
|
|
@@ -135,7 +135,7 @@ function isDomainAllowed(domain: string, config: SandboxConfig): boolean {
|
|
|
135
135
|
);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
function
|
|
138
|
+
function isReadAllowed(
|
|
139
139
|
path: string,
|
|
140
140
|
allowPatterns: string[],
|
|
141
141
|
denyPatterns: string[],
|
|
@@ -146,6 +146,16 @@ function isFilesystemAllowed(
|
|
|
146
146
|
return allowDepth >= 0 && allowDepth >= denyDepth;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function writeAccess(
|
|
150
|
+
path: string,
|
|
151
|
+
allowPatterns: string[],
|
|
152
|
+
denyPatterns: string[],
|
|
153
|
+
baseDirectory: string,
|
|
154
|
+
): 'allow' | 'deny' | 'unlisted' {
|
|
155
|
+
if (matchDepth(path, denyPatterns, baseDirectory) >= 0) return 'deny';
|
|
156
|
+
return matchDepth(path, allowPatterns, baseDirectory) >= 0 ? 'allow' : 'unlisted';
|
|
157
|
+
}
|
|
158
|
+
|
|
149
159
|
function extractCandidatePaths(command: string): string[] {
|
|
150
160
|
const paths: string[] = [];
|
|
151
161
|
const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/g) ?? [];
|
|
@@ -219,9 +229,7 @@ function evaluateReadPermission(
|
|
|
219
229
|
// everything else asks for approval (allow once/session/persist or reject)
|
|
220
230
|
// rather than being blocked outright. denyRead still hard-applies to bash
|
|
221
231
|
// through the landstrip binary policy, which has no way to prompt.
|
|
222
|
-
if (
|
|
223
|
-
isFilesystemAllowed(filePath, effectiveAllowRead, config.filesystem.denyRead, baseDirectory)
|
|
224
|
-
) {
|
|
232
|
+
if (isReadAllowed(filePath, effectiveAllowRead, config.filesystem.denyRead, baseDirectory)) {
|
|
225
233
|
return { status: 'allow', kind: 'read', resource: filePath, message: '' };
|
|
226
234
|
}
|
|
227
235
|
|
|
@@ -240,10 +248,14 @@ function evaluateWritePermission(
|
|
|
240
248
|
effectiveAllowWrite: string[],
|
|
241
249
|
): SandboxPermissionDecision {
|
|
242
250
|
const filePath = canonicalizePath(path, baseDirectory);
|
|
243
|
-
const
|
|
244
|
-
|
|
251
|
+
const access = writeAccess(
|
|
252
|
+
filePath,
|
|
253
|
+
effectiveAllowWrite,
|
|
254
|
+
config.filesystem.denyWrite,
|
|
255
|
+
baseDirectory,
|
|
256
|
+
);
|
|
245
257
|
|
|
246
|
-
if (
|
|
258
|
+
if (access === 'deny') {
|
|
247
259
|
return {
|
|
248
260
|
status: 'deny',
|
|
249
261
|
kind: 'write',
|
|
@@ -252,9 +264,7 @@ function evaluateWritePermission(
|
|
|
252
264
|
};
|
|
253
265
|
}
|
|
254
266
|
|
|
255
|
-
if (
|
|
256
|
-
isFilesystemAllowed(filePath, effectiveAllowWrite, config.filesystem.denyWrite, baseDirectory)
|
|
257
|
-
) {
|
|
267
|
+
if (access === 'allow') {
|
|
258
268
|
return { status: 'allow', kind: 'write', resource: filePath, message: '' };
|
|
259
269
|
}
|
|
260
270
|
|
|
@@ -427,8 +437,8 @@ function startTrapServer(
|
|
|
427
437
|
const operation = trap.operation;
|
|
428
438
|
const allowed =
|
|
429
439
|
operation === 'read'
|
|
430
|
-
?
|
|
431
|
-
:
|
|
440
|
+
? isReadAllowed(path, effectiveAllowRead, denyRead, baseDirectory)
|
|
441
|
+
: writeAccess(path, effectiveAllowWrite, denyWrite, baseDirectory) === 'allow';
|
|
432
442
|
if (allowed) {
|
|
433
443
|
trapSocket.write(controlResponseLine(queryId, 'allow'));
|
|
434
444
|
} else {
|
|
@@ -499,7 +509,7 @@ function buildWrappedCommand(
|
|
|
499
509
|
const trapped = [
|
|
500
510
|
landstripBinaryPath(),
|
|
501
511
|
'run',
|
|
502
|
-
'--trap
|
|
512
|
+
'--trap',
|
|
503
513
|
'3',
|
|
504
514
|
'-p',
|
|
505
515
|
policyPath,
|
|
@@ -1051,7 +1061,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1051
1061
|
config.filesystem.allowWrite,
|
|
1052
1062
|
);
|
|
1053
1063
|
for (const decision of decisions) {
|
|
1054
|
-
if (
|
|
1064
|
+
if (
|
|
1065
|
+
decision.status === 'allow' ||
|
|
1066
|
+
(decision.status === 'ask' && hasCallAllowance(input.callID, decision))
|
|
1067
|
+
) {
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1055
1070
|
reportBlocked(decision);
|
|
1056
1071
|
}
|
|
1057
1072
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "OS-level AI command sandboxing for OpenCode with Landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"ci:test": "npm test"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@landstrip/landstrip-api": "^0.
|
|
51
|
+
"@landstrip/landstrip-api": "^0.19.0",
|
|
52
52
|
"@opentui/core": ">=0.3.4",
|
|
53
53
|
"@opentui/solid": ">=0.3.4",
|
|
54
54
|
"solid-js": "1.9.12"
|
package/shared.ts
CHANGED
|
@@ -1,33 +1,10 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
type LandstripControlResponse,
|
|
7
|
-
type LandstripTrap,
|
|
8
|
-
} from '@landstrip/landstrip-api';
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
allowsAllDomains,
|
|
12
|
-
canonicalizeGlobPattern,
|
|
13
|
-
canonicalizePath,
|
|
14
|
-
controlResponseLine,
|
|
15
|
-
decodeLandstripTrap,
|
|
16
|
-
domainMatchesAny,
|
|
17
|
-
domainMatchesPattern,
|
|
18
|
-
expandHomePath,
|
|
19
|
-
expandPath,
|
|
20
|
-
globToRegExp,
|
|
21
|
-
formatLandstripTrap,
|
|
22
|
-
formatLandstripTraps,
|
|
23
|
-
isRecord,
|
|
24
|
-
normalizePathSeparators,
|
|
25
|
-
parseLandstripTraps,
|
|
26
|
-
pathUnderDirectory,
|
|
27
|
-
sessionAllows,
|
|
28
|
-
sessionScopeFor,
|
|
29
|
-
} from '@landstrip/landstrip-api/shared';
|
|
4
|
+
import { binaryPath } from '@landstrip/landstrip-api';
|
|
5
|
+
import { isRecord } from '@landstrip/landstrip-api/shared';
|
|
30
6
|
|
|
7
|
+
export type { LandstripControlResponse, LandstripTrap } from '@landstrip/landstrip-api';
|
|
31
8
|
export {
|
|
32
9
|
allowsAllDomains,
|
|
33
10
|
canonicalizeGlobPattern,
|
|
@@ -47,11 +24,7 @@ export {
|
|
|
47
24
|
pathUnderDirectory,
|
|
48
25
|
sessionAllows,
|
|
49
26
|
sessionScopeFor,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// Re-exported so index.ts/tui.ts can import the trap/response types from this
|
|
53
|
-
// module alongside the parsing functions that produce/consume them.
|
|
54
|
-
export type { LandstripControlResponse, LandstripTrap };
|
|
27
|
+
} from '@landstrip/landstrip-api/shared';
|
|
55
28
|
|
|
56
29
|
import { createHash } from 'node:crypto';
|
|
57
30
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -99,10 +72,6 @@ const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
|
99
72
|
'@landstrip/landstrip-win32-arm64',
|
|
100
73
|
]);
|
|
101
74
|
|
|
102
|
-
export function list(values: string[]): string {
|
|
103
|
-
return values.join(', ') || '(none)';
|
|
104
|
-
}
|
|
105
|
-
|
|
106
75
|
function rejectUnknownFields(
|
|
107
76
|
value: Record<string, unknown>,
|
|
108
77
|
fields: readonly string[],
|
|
@@ -601,54 +570,3 @@ export function readDiscoveryPort(baseDirectory: string): number | null {
|
|
|
601
570
|
return null;
|
|
602
571
|
}
|
|
603
572
|
}
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* Human-readable sandbox configuration report consumed by both the server
|
|
607
|
-
* command and the TUI inspector dialog.
|
|
608
|
-
*/
|
|
609
|
-
export function sandboxSummary(
|
|
610
|
-
config: SandboxConfig,
|
|
611
|
-
globalPath: string,
|
|
612
|
-
projectPath: string,
|
|
613
|
-
statusOverride?: string,
|
|
614
|
-
): string {
|
|
615
|
-
const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
|
|
616
|
-
const allowed = list(config.network.allowedDomains);
|
|
617
|
-
const denied = list(config.network.deniedDomains);
|
|
618
|
-
const unixSockets = config.network.allowAllUnixSockets
|
|
619
|
-
? 'all'
|
|
620
|
-
: list(config.network.allowUnixSockets);
|
|
621
|
-
const denyRead = list(config.filesystem.denyRead);
|
|
622
|
-
const allowRead = list(config.filesystem.allowRead);
|
|
623
|
-
const allowWrite = list(config.filesystem.allowWrite);
|
|
624
|
-
const denyWrite = list(config.filesystem.denyWrite);
|
|
625
|
-
|
|
626
|
-
const status = statusOverride ?? (config.enabled ? 'active' : 'disabled by config');
|
|
627
|
-
let binary: string;
|
|
628
|
-
try {
|
|
629
|
-
binary = landstripBinaryPath();
|
|
630
|
-
} catch {
|
|
631
|
-
binary = '(unavailable)';
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
return [
|
|
635
|
-
`Status: ${status}`,
|
|
636
|
-
`landstrip package binary: ${binary}`,
|
|
637
|
-
'',
|
|
638
|
-
'Config files',
|
|
639
|
-
`${projectPath} ${existsSync(projectPath) ? '(found)' : '(missing)'}`,
|
|
640
|
-
`${globalPath} ${existsSync(globalPath) ? '(found)' : '(missing)'}`,
|
|
641
|
-
'',
|
|
642
|
-
`Network: ${networkMode}`,
|
|
643
|
-
`allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
|
|
644
|
-
`allowed: ${allowed}`,
|
|
645
|
-
`denied: ${denied}`,
|
|
646
|
-
`unix sockets: ${unixSockets}`,
|
|
647
|
-
'',
|
|
648
|
-
'Filesystem',
|
|
649
|
-
`deny read: ${denyRead}`,
|
|
650
|
-
`allow read: ${allowRead}`,
|
|
651
|
-
`allow write: ${allowWrite}`,
|
|
652
|
-
`deny write: ${denyWrite}`,
|
|
653
|
-
].join('\n');
|
|
654
|
-
}
|
package/tui.ts
CHANGED
|
@@ -585,6 +585,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
585
585
|
}
|
|
586
586
|
|
|
587
587
|
if (trap.kind === 'filesystem') {
|
|
588
|
+
if (trap.operation === 'write' && trap.reason === 'deny_match') {
|
|
589
|
+
respondQuery(socket, trap.query_id, 'deny');
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
588
592
|
const sessionPaths =
|
|
589
593
|
trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
|
|
590
594
|
if (sessionAllows(sessionPaths, trap.path)) {
|