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 CHANGED
@@ -1,51 +1,44 @@
1
1
  # opencode-landstrip
2
2
 
3
- OpenCode plugin that runs AI `bash` calls through Landstrip.
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
- # Current project
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
- The installer configures both the server plugin and TUI presenter.
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
- OpenCode permissions authorize tool dispatch. Landstrip permissions authorize
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 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`.
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
- ## Configuration
22
+ Policy precedence (persistent approvals update the project/global files):
29
23
 
30
- Policy merges in this order:
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
- 1. bundled `sandbox.json`;
33
- 2. `~/.config/opencode/sandbox.json`;
34
- 3. `.opencode/sandbox.json`;
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
- Objects merge recursively, arrays combine, and later scalar values replace
38
- earlier values. The bundled policy denies sensitive files and network access
39
- while allowing project writes. See [`sandbox.json`](sandbox.json) for its exact
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
- If sandboxing is enabled but the binary or platform is unusable, AI Bash fails
43
- closed. Set `enabled` to `false` explicitly to allow unsandboxed AI Bash.
37
+ ## Development
44
38
 
45
- Only AI `bash` calls receive OS isolation; direct user shell commands cannot be
46
- replaced through OpenCode's plugin API.
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
- ## Licensing
42
+ ## License
49
43
 
50
- `opencode-landstrip` is licensed under the Apache 2.0 license. See [LICENSE](LICENSE)
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. Used to rank how specific a
82
- // matching pattern is so the most specific allow/deny rule wins.
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 isFilesystemAllowed(
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 allowDepth = matchDepth(filePath, effectiveAllowWrite, baseDirectory);
244
- const denyDepth = matchDepth(filePath, config.filesystem.denyWrite, baseDirectory);
251
+ const access = writeAccess(
252
+ filePath,
253
+ effectiveAllowWrite,
254
+ config.filesystem.denyWrite,
255
+ baseDirectory,
256
+ );
245
257
 
246
- if (denyDepth >= 0 && denyDepth >= allowDepth) {
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
- ? isFilesystemAllowed(path, effectiveAllowRead, denyRead, baseDirectory)
431
- : isFilesystemAllowed(path, effectiveAllowWrite, denyWrite, baseDirectory);
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-fd',
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 (decision.status === 'allow' || hasCallAllowance(input.callID, decision)) continue;
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.18.42",
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.18.42",
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
- binaryPath,
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)) {