opencode-landstrip 0.18.41 → 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 +50 -63
  2. package/package.json +2 -2
  3. package/shared.ts +4 -86
  4. package/tui.ts +14 -24
package/index.ts CHANGED
@@ -74,17 +74,12 @@ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
74
74
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
75
75
  const DISCOVERY_CONNECT_TIMEOUT_MS = 250;
76
76
 
77
- function configuredShellPath(config: unknown): string | undefined {
78
- if (!isRecord(config)) return undefined;
79
- return typeof config.shell === 'string' ? config.shell : undefined;
80
- }
81
-
82
77
  function normalizePathForMatch(filePath: string): string {
83
78
  return process.platform === 'win32' ? normalizePathSeparators(filePath).toLowerCase() : filePath;
84
79
  }
85
80
 
86
- // Component count of an absolute path; "/" is 0. Used to rank how specific a
87
- // 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.
88
83
  function pathDepth(absolutePath: string): number {
89
84
  return absolutePath.split('/').filter((segment) => segment.length > 0).length;
90
85
  }
@@ -140,7 +135,7 @@ function isDomainAllowed(domain: string, config: SandboxConfig): boolean {
140
135
  );
141
136
  }
142
137
 
143
- function isFilesystemAllowed(
138
+ function isReadAllowed(
144
139
  path: string,
145
140
  allowPatterns: string[],
146
141
  denyPatterns: string[],
@@ -151,6 +146,16 @@ function isFilesystemAllowed(
151
146
  return allowDepth >= 0 && allowDepth >= denyDepth;
152
147
  }
153
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
+
154
159
  function extractCandidatePaths(command: string): string[] {
155
160
  const paths: string[] = [];
156
161
  const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/g) ?? [];
@@ -224,9 +229,7 @@ function evaluateReadPermission(
224
229
  // everything else asks for approval (allow once/session/persist or reject)
225
230
  // rather than being blocked outright. denyRead still hard-applies to bash
226
231
  // through the landstrip binary policy, which has no way to prompt.
227
- if (
228
- isFilesystemAllowed(filePath, effectiveAllowRead, config.filesystem.denyRead, baseDirectory)
229
- ) {
232
+ if (isReadAllowed(filePath, effectiveAllowRead, config.filesystem.denyRead, baseDirectory)) {
230
233
  return { status: 'allow', kind: 'read', resource: filePath, message: '' };
231
234
  }
232
235
 
@@ -245,10 +248,14 @@ function evaluateWritePermission(
245
248
  effectiveAllowWrite: string[],
246
249
  ): SandboxPermissionDecision {
247
250
  const filePath = canonicalizePath(path, baseDirectory);
248
- const allowDepth = matchDepth(filePath, effectiveAllowWrite, baseDirectory);
249
- const denyDepth = matchDepth(filePath, config.filesystem.denyWrite, baseDirectory);
251
+ const access = writeAccess(
252
+ filePath,
253
+ effectiveAllowWrite,
254
+ config.filesystem.denyWrite,
255
+ baseDirectory,
256
+ );
250
257
 
251
- if (denyDepth >= 0 && denyDepth >= allowDepth) {
258
+ if (access === 'deny') {
252
259
  return {
253
260
  status: 'deny',
254
261
  kind: 'write',
@@ -257,9 +264,7 @@ function evaluateWritePermission(
257
264
  };
258
265
  }
259
266
 
260
- if (
261
- isFilesystemAllowed(filePath, effectiveAllowWrite, config.filesystem.denyWrite, baseDirectory)
262
- ) {
267
+ if (access === 'allow') {
263
268
  return { status: 'allow', kind: 'write', resource: filePath, message: '' };
264
269
  }
265
270
 
@@ -310,12 +315,6 @@ function evaluateCommandDomains(
310
315
  );
311
316
  }
312
317
 
313
- function landstripVersion(): string | null {
314
- const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
315
- if (result.status !== 0) return null;
316
- return result.stdout.trim();
317
- }
318
-
319
318
  function parseVersion(version: string): [number, number, number] | null {
320
319
  const match = version.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
321
320
  if (!match) return null;
@@ -438,8 +437,8 @@ function startTrapServer(
438
437
  const operation = trap.operation;
439
438
  const allowed =
440
439
  operation === 'read'
441
- ? isFilesystemAllowed(path, effectiveAllowRead, denyRead, baseDirectory)
442
- : isFilesystemAllowed(path, effectiveAllowWrite, denyWrite, baseDirectory);
440
+ ? isReadAllowed(path, effectiveAllowRead, denyRead, baseDirectory)
441
+ : writeAccess(path, effectiveAllowWrite, denyWrite, baseDirectory) === 'allow';
443
442
  if (allowed) {
444
443
  trapSocket.write(controlResponseLine(queryId, 'allow'));
445
444
  } else {
@@ -492,12 +491,6 @@ function trapPortAcceptsConnections(port: number): Promise<boolean> {
492
491
  });
493
492
  }
494
493
 
495
- async function readLiveDiscoveryPort(baseDirectory: string): Promise<number | null> {
496
- const port = readDiscoveryPort(baseDirectory);
497
- if (port === null) return null;
498
- return (await trapPortAcceptsConnections(port)) ? port : null;
499
- }
500
-
501
494
  function buildWrappedCommand(
502
495
  policyPath: string,
503
496
  shell: string,
@@ -542,10 +535,6 @@ function isGeneratedWrappedCommand(command: string): boolean {
542
535
  );
543
536
  }
544
537
 
545
- function landstripDescription(description: string): string {
546
- return description.endsWith(' (landstrip)') ? description : `${description} (landstrip)`;
547
- }
548
-
549
538
  function splitShellQuotedArgs(command: string): string[] {
550
539
  const args: string[] = [];
551
540
  let i = 0;
@@ -597,10 +586,6 @@ function getToolPath(args: Record<string, unknown>): string | undefined {
597
586
  return typeof filePath === 'string' ? filePath : undefined;
598
587
  }
599
588
 
600
- function getSearchPath(args: Record<string, unknown>): string {
601
- return typeof args.path === 'string' ? args.path : '.';
602
- }
603
-
604
589
  function extractPatchPaths(patchText: string): string[] {
605
590
  const paths: string[] = [];
606
591
 
@@ -636,7 +621,8 @@ function evaluateToolPermissions(
636
621
  }
637
622
 
638
623
  if (tool === 'glob' || tool === 'grep' || tool === 'list') {
639
- return [evaluateReadPermission(getSearchPath(args), config, baseDirectory, effectiveAllowRead)];
624
+ const searchPath = typeof args.path === 'string' ? args.path : '.';
625
+ return [evaluateReadPermission(searchPath, config, baseDirectory, effectiveAllowRead)];
640
626
  }
641
627
 
642
628
  if (tool === 'write' || tool === 'edit') {
@@ -679,16 +665,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
679
665
  return `${callID}:${kind}:${resource}`;
680
666
  }
681
667
 
682
- function rememberCallAllowances(
683
- callID: string | undefined,
684
- approvals: readonly SandboxPermissionDecision[],
685
- ): void {
686
- if (!callID) return;
687
- for (const approval of approvals) {
688
- callAllowances.add(allowanceKey(callID, approval.kind, approval.resource));
689
- }
690
- }
691
-
692
668
  function clearCallAllowances(callID: string): void {
693
669
  for (const key of callAllowances) {
694
670
  if (key.startsWith(`${callID}:`)) callAllowances.delete(key);
@@ -712,11 +688,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
712
688
  throw errorWithConfigPaths(directory, decision.message);
713
689
  }
714
690
 
715
- function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
716
- if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
717
- reportBlocked(decision);
718
- }
719
-
720
691
  client.app
721
692
  ?.log?.({
722
693
  body: {
@@ -787,7 +758,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
787
758
 
788
759
  let version: string | null;
789
760
  try {
790
- version = landstripVersion();
761
+ const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
762
+ version = result.status === 0 ? result.stdout.trim() : null;
791
763
  } catch (error) {
792
764
  landstripCheck = {
793
765
  ok: false,
@@ -889,8 +861,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
889
861
  const normalizedSessionID = sessionID?.trim() || undefined;
890
862
 
891
863
  const rewriteDescription = (): void => {
892
- if (typeof args.description === 'string')
893
- args.description = landstripDescription(args.description);
864
+ if (typeof args.description === 'string' && !args.description.endsWith(' (landstrip)')) {
865
+ args.description = `${args.description} (landstrip)`;
866
+ }
894
867
  };
895
868
 
896
869
  const existing = activeBash.get(callID);
@@ -965,9 +938,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
965
938
  // The TUI owns interactive query handling. Fall back to an in-process
966
939
  // broker when no TUI endpoint or session identity is available.
967
940
  const interactiveSessionID = normalizedSessionID ?? '';
941
+ const discoveredPort =
942
+ process.platform === 'linux' && interactiveSessionID ? readDiscoveryPort(directory) : null;
968
943
  const tuiTrapPort =
969
- process.platform === 'linux' && interactiveSessionID
970
- ? await readLiveDiscoveryPort(directory)
944
+ discoveredPort !== null && (await trapPortAcceptsConnections(discoveredPort))
945
+ ? discoveredPort
971
946
  : null;
972
947
  const trapServer =
973
948
  tuiTrapPort === null
@@ -1008,7 +983,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1008
983
 
1009
984
  const hooks: Hooks = {
1010
985
  config: async (config) => {
1011
- configuredShell = configuredShellPath(config);
986
+ const value: unknown = config;
987
+ configuredShell =
988
+ isRecord(value) && typeof value.shell === 'string' ? value.shell : undefined;
1012
989
  },
1013
990
 
1014
991
  'permission.ask': async (input, output) => {
@@ -1057,7 +1034,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1057
1034
  if (approvals.length === 0) return;
1058
1035
 
1059
1036
  output.status = 'ask';
1060
- rememberCallAllowances(callID, approvals);
1037
+ if (callID) {
1038
+ for (const approval of approvals) {
1039
+ callAllowances.add(allowanceKey(callID, approval.kind, approval.resource));
1040
+ }
1041
+ }
1061
1042
  },
1062
1043
 
1063
1044
  'tool.execute.before': async (input, output) => {
@@ -1080,7 +1061,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1080
1061
  config.filesystem.allowWrite,
1081
1062
  );
1082
1063
  for (const decision of decisions) {
1083
- enforcePermission(input.callID, decision);
1064
+ if (
1065
+ decision.status === 'allow' ||
1066
+ (decision.status === 'ask' && hasCallAllowance(input.callID, decision))
1067
+ ) {
1068
+ continue;
1069
+ }
1070
+ reportBlocked(decision);
1084
1071
  }
1085
1072
  },
1086
1073
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.18.41",
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.41",
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
@@ -118,11 +118,6 @@ function formatPath(input: string, base: string): string {
118
118
  return absolute;
119
119
  }
120
120
 
121
- function selectedForeground(background: RGBA): RGBA {
122
- const luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
123
- return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
124
- }
125
-
126
121
  const tui: TuiPlugin = async (api, options, meta) => {
127
122
  const optionOverrides = normalizeOptions(options);
128
123
  const enabledManagedByOptions = optionOverrides.enabled !== undefined;
@@ -176,7 +171,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
176
171
  },
177
172
  children: jsx('text', {
178
173
  get fg() {
179
- return selected() === index ? selectedForeground(theme.warning) : theme.textMuted;
174
+ if (selected() !== index) return theme.textMuted;
175
+ const background = theme.warning;
176
+ const luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
177
+ return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
180
178
  },
181
179
  children: option.label,
182
180
  }),
@@ -333,14 +331,11 @@ const tui: TuiPlugin = async (api, options, meta) => {
333
331
  );
334
332
  }
335
333
 
336
- function currentRouteSessionID(): string | undefined {
337
- const route = api.route.current;
338
- if (route.name !== 'session' || !route.params) return undefined;
339
- return typeof route.params.sessionID === 'string' ? route.params.sessionID : undefined;
340
- }
341
-
342
334
  function pump(): void {
343
- const routeSessionID = currentRouteSessionID();
335
+ const route = api.route.current;
336
+ if (route.name !== 'session' || !route.params) return;
337
+ const routeSessionID =
338
+ typeof route.params.sessionID === 'string' ? route.params.sessionID : undefined;
344
339
  if (!routeSessionID) return;
345
340
 
346
341
  const active = activeEntry();
@@ -360,8 +355,8 @@ const tui: TuiPlugin = async (api, options, meta) => {
360
355
  if (index === -1) return;
361
356
  const [next] = queue.splice(index, 1);
362
357
  if (!next) return;
363
- if (next.kind === 'fs-query') showFsQuery(next);
364
- else showNetworkQuery(next);
358
+ activeId = next.id;
359
+ setActiveEntry(next);
365
360
  }
366
361
 
367
362
  function enqueueEntry(entry: QueueEntry): void {
@@ -505,10 +500,6 @@ const tui: TuiPlugin = async (api, options, meta) => {
505
500
  }
506
501
  }
507
502
 
508
- function showFsQuery(entry: FsQueryEntry): void {
509
- activeId = entry.id;
510
- setActiveEntry(entry);
511
- }
512
503
  function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
513
504
  if (resolved.has(entry.id)) return;
514
505
  const action = choice === 'deny' ? 'deny' : 'allow';
@@ -533,11 +524,6 @@ const tui: TuiPlugin = async (api, options, meta) => {
533
524
  }
534
525
  }
535
526
 
536
- function showNetworkQuery(entry: NetworkQueryEntry): void {
537
- activeId = entry.id;
538
- setActiveEntry(entry);
539
- }
540
-
541
527
  // Query-response socket server (Linux-only — landstrip's socket protocol lives
542
528
  // in the seccomp broker). The server plugin connects each sandboxed run's
543
529
  // fd 3 here via a /dev/tcp redirect and we answer held writes interactively.
@@ -599,6 +585,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
599
585
  }
600
586
 
601
587
  if (trap.kind === 'filesystem') {
588
+ if (trap.operation === 'write' && trap.reason === 'deny_match') {
589
+ respondQuery(socket, trap.query_id, 'deny');
590
+ continue;
591
+ }
602
592
  const sessionPaths =
603
593
  trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
604
594
  if (sessionAllows(sessionPaths, trap.path)) {