opencode-landstrip 0.18.42 → 0.18.43

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.
Files changed (4) hide show
  1. package/index.ts +30 -15
  2. package/package.json +2 -2
  3. package/shared.ts +4 -86
  4. package/tui.ts +4 -0
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 {
@@ -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.18.43",
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.18.43",
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)) {