opencode-landstrip 0.15.15 → 0.15.17

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 (3) hide show
  1. package/index.ts +59 -127
  2. package/package.json +2 -2
  3. package/shared.ts +22 -0
package/index.ts CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  loadConfig,
23
23
  normalizeOptions,
24
24
  parseLandstripTraps,
25
+ permissionPatterns,
26
+ permissionType,
25
27
  readDiscoveryPort,
26
28
  } from './shared.js';
27
29
 
@@ -55,7 +57,7 @@ interface SandboxPermissionDecision {
55
57
 
56
58
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
57
59
 
58
- const LANDSTRIP_VERSION = [0, 15, 9] as const;
60
+ const LANDSTRIP_VERSION = [0, 15, 14] as const;
59
61
  const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
60
62
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
61
63
 
@@ -125,21 +127,37 @@ function globToRegExp(globPattern: string): RegExp {
125
127
  return new RegExp(`^${regex}$`);
126
128
  }
127
129
 
128
- function matchesPattern(filePath: string, patterns: string[], baseDirectory: string): boolean {
129
- const abs = canonicalizePath(filePath, baseDirectory);
130
+ // Component count of an absolute path; "/" is 0. Used to rank how specific a
131
+ // matching pattern is so the most specific allow/deny rule wins.
132
+ function pathDepth(absolutePath: string): number {
133
+ return absolutePath.split('/').filter((segment) => segment.length > 0).length;
134
+ }
130
135
 
131
- return patterns.some((pattern) => {
132
- const absPattern = pattern.includes('*')
133
- ? expandPath(pattern, baseDirectory)
134
- : canonicalizePath(pattern, baseDirectory);
136
+ // The depth of the most specific pattern that matches `filePath`, or -1 when
137
+ // none match. A glob is anchored to the whole path, so it ranks at the path's
138
+ // own depth; a literal pattern ranks at the depth of the prefix it covers.
139
+ function matchDepth(filePath: string, patterns: string[], baseDirectory: string): number {
140
+ const abs = canonicalizePath(filePath, baseDirectory);
141
+ let depth = -1;
135
142
 
143
+ for (const pattern of patterns) {
136
144
  if (pattern.includes('*')) {
137
- return globToRegExp(absPattern).test(abs);
145
+ const absPattern = expandPath(pattern, baseDirectory);
146
+ if (globToRegExp(absPattern).test(abs)) depth = Math.max(depth, pathDepth(abs));
147
+ } else {
148
+ const absPattern = canonicalizePath(pattern, baseDirectory);
149
+ const sep = absPattern.endsWith('/') ? '' : '/';
150
+ if (abs === absPattern || abs.startsWith(absPattern + sep)) {
151
+ depth = Math.max(depth, pathDepth(absPattern));
152
+ }
138
153
  }
154
+ }
139
155
 
140
- const sep = absPattern.endsWith('/') ? '' : '/';
141
- return abs === absPattern || abs.startsWith(absPattern + sep);
142
- });
156
+ return depth;
157
+ }
158
+
159
+ function matchesPattern(filePath: string, patterns: string[], baseDirectory: string): boolean {
160
+ return matchDepth(filePath, patterns, baseDirectory) >= 0;
143
161
  }
144
162
 
145
163
  function resolveFilesystemPatterns(patterns: string[], baseDirectory: string): string[] {
@@ -162,10 +180,6 @@ function resolveFilesystemConfig(
162
180
  };
163
181
  }
164
182
 
165
- function shouldPromptForRead(path: string, allowRead: string[], baseDirectory: string): boolean {
166
- return allowRead.length === 0 || !matchesPattern(path, allowRead, baseDirectory);
167
- }
168
-
169
183
  function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory: string): boolean {
170
184
  return allowWrite.length === 0 || !matchesPattern(path, allowWrite, baseDirectory);
171
185
  }
@@ -265,27 +279,6 @@ function extractBlockedWritePath(
265
279
  return extractBlockedPath(output, baseDirectory, command);
266
280
  }
267
281
 
268
- function isBlockedByDenyRead(path: string, config: SandboxConfig, baseDirectory: string): boolean {
269
- return matchesPattern(path, config.filesystem.denyRead, baseDirectory);
270
- }
271
-
272
- function firstBlockedDomain(
273
- command: string,
274
- config: SandboxConfig,
275
- ): { domain: string; reason: 'allowedDomains' | 'deniedDomains' } | null {
276
- for (const domain of extractDomainsFromCommand(command)) {
277
- if (domainMatchesAny(domain, config.network.deniedDomains)) {
278
- return { domain, reason: 'deniedDomains' };
279
- }
280
-
281
- if (!domainMatchesAny(domain, config.network.allowedDomains)) {
282
- return { domain, reason: 'allowedDomains' };
283
- }
284
- }
285
-
286
- return null;
287
- }
288
-
289
282
  function evaluateReadPermission(
290
283
  path: string,
291
284
  config: SandboxConfig,
@@ -293,8 +286,13 @@ function evaluateReadPermission(
293
286
  effectiveAllowRead: string[],
294
287
  ): SandboxPermissionDecision {
295
288
  const filePath = canonicalizePath(path, baseDirectory);
289
+ const allowDepth = matchDepth(filePath, effectiveAllowRead, baseDirectory);
290
+ const denyDepth = matchDepth(filePath, config.filesystem.denyRead, baseDirectory);
296
291
 
297
- if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
292
+ // The most specific rule wins, matching landstrip's read policy so the bash
293
+ // and read tools agree: a denyRead overrides allowRead only when it is more
294
+ // specific, while a tie or a more specific allowRead carves the path back in.
295
+ if (denyDepth > allowDepth) {
298
296
  return {
299
297
  status: 'deny',
300
298
  kind: 'read',
@@ -303,7 +301,7 @@ function evaluateReadPermission(
303
301
  };
304
302
  }
305
303
 
306
- if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
304
+ if (allowDepth >= 0) {
307
305
  return { status: 'allow', kind: 'read', resource: filePath, message: '' };
308
306
  }
309
307
 
@@ -754,27 +752,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
754
752
  const optionOverrides = normalizeOptions(options);
755
753
  const activeBash = new Map<string, BashSandboxState>();
756
754
  const notified = new Set<string>();
757
- const sessionAllowedReadPaths: string[] = [];
758
- const sessionAllowedWritePaths: string[] = [];
759
- const sessionAllowedDomains: string[] = [];
760
755
  const callAllowances = new Set<string>();
761
756
  let enabledNotified = false;
762
757
  let sandboxDisabled = false;
763
758
  let configuredShell: string | undefined;
764
759
  let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
765
760
 
766
- function getEffectiveAllowRead(config: SandboxConfig): string[] {
767
- return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
768
- }
769
-
770
- function getEffectiveAllowWrite(config: SandboxConfig): string[] {
771
- return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
772
- }
773
-
774
- function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
775
- return [...config.network.allowedDomains, ...sessionAllowedDomains];
776
- }
777
-
778
761
  function allowanceKey(callID: string, kind: SandboxPermissionKind, resource: string): string {
779
762
  return `${callID}:${kind}:${resource}`;
780
763
  }
@@ -791,8 +774,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
791
774
  return callAllowances.has(allowanceKey(callID, decision.kind, decision.resource));
792
775
  }
793
776
 
794
- function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
795
- if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
777
+ function reportBlocked(decision: SandboxPermissionDecision): never {
796
778
  client.tui
797
779
  ?.showToast?.({
798
780
  body: {
@@ -805,6 +787,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
805
787
  throw errorWithConfigPaths(directory, decision.message);
806
788
  }
807
789
 
790
+ function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
791
+ if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
792
+ reportBlocked(decision);
793
+ }
794
+
808
795
  function pushCommandText(
809
796
  input: { sessionID: string },
810
797
  output: { parts: unknown[] },
@@ -822,11 +809,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
822
809
  function sandboxSummary(config: SandboxConfig): string {
823
810
  const { globalPath, projectPath } = getConfigPaths(directory);
824
811
  const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
825
- const allowed = getEffectiveAllowedDomains(config).join(', ') || '(none)';
812
+ const allowed = config.network.allowedDomains.join(', ') || '(none)';
826
813
  const denied = config.network.deniedDomains.join(', ') || '(none)';
827
814
  const denyRead = config.filesystem.denyRead.join(', ') || '(none)';
828
- const allowRead = getEffectiveAllowRead(config).join(', ') || '(none)';
829
- const allowWrite = getEffectiveAllowWrite(config).join(', ') || '(none)';
815
+ const allowRead = config.filesystem.allowRead.join(', ') || '(none)';
816
+ const allowWrite = config.filesystem.allowWrite.join(', ') || '(none)';
830
817
  const denyWrite = config.filesystem.denyWrite.join(', ') || '(none)';
831
818
 
832
819
  return [
@@ -1050,7 +1037,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1050
1037
  const callAllowedDomains: string[] = [];
1051
1038
  const effectiveConfig = {
1052
1039
  ...config,
1053
- network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1040
+ network: { ...config.network },
1054
1041
  };
1055
1042
 
1056
1043
  if (!allowNetwork) {
@@ -1114,14 +1101,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1114
1101
  if (!config) return;
1115
1102
 
1116
1103
  const request = input as Record<string, unknown>;
1117
- const permission =
1118
- typeof request.type === 'string'
1119
- ? request.type
1120
- : typeof request.permission === 'string'
1121
- ? request.permission
1122
- : typeof request.action === 'string'
1123
- ? request.action
1124
- : '';
1104
+ const permission = permissionType(request);
1125
1105
  const metadata = isRecord(request.metadata) ? request.metadata : {};
1126
1106
  const tool = isRecord(request.tool) ? request.tool : undefined;
1127
1107
  const callID =
@@ -1130,17 +1110,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1130
1110
  : typeof tool?.callID === 'string'
1131
1111
  ? tool.callID
1132
1112
  : undefined;
1133
- const patterns = Array.isArray(request.patterns)
1134
- ? request.patterns.filter((item): item is string => typeof item === 'string')
1135
- : typeof request.pattern === 'string'
1136
- ? [request.pattern]
1137
- : Array.isArray(request.resources)
1138
- ? request.resources.filter((item): item is string => typeof item === 'string')
1139
- : [];
1113
+ const patterns = permissionPatterns(request);
1140
1114
 
1141
1115
  const decisions: SandboxPermissionDecision[] = [];
1142
- const effectiveAllowRead = getEffectiveAllowRead(config);
1143
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1116
+ const effectiveAllowRead = config.filesystem.allowRead;
1117
+ const effectiveAllowWrite = config.filesystem.allowWrite;
1144
1118
 
1145
1119
  if (permission === 'read') {
1146
1120
  for (const pattern of patterns) {
@@ -1189,8 +1163,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1189
1163
  const config = await activeConfig();
1190
1164
  if (!config) return;
1191
1165
 
1192
- const effectiveAllowRead = getEffectiveAllowRead(config);
1193
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1166
+ const effectiveAllowRead = config.filesystem.allowRead;
1167
+ const effectiveAllowWrite = config.filesystem.allowWrite;
1194
1168
 
1195
1169
  if (input.tool === 'bash') {
1196
1170
  await prepareBash(input.callID, output.args, config);
@@ -1373,23 +1347,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1373
1347
  const config = await activeConfig();
1374
1348
  if (!config) return;
1375
1349
 
1376
- const effectiveAllowRead = getEffectiveAllowRead(config);
1377
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1350
+ const effectiveAllowRead = config.filesystem.allowRead;
1351
+ const effectiveAllowWrite = config.filesystem.allowWrite;
1378
1352
 
1379
1353
  for (const path of extractCandidatePaths(shellCommand)) {
1380
1354
  const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
1381
- if (readDecision.status === 'deny') {
1382
- client.tui
1383
- ?.showToast?.({
1384
- body: {
1385
- title: 'Sandbox blocked',
1386
- message: readDecision.message.slice(0, 120),
1387
- variant: 'error',
1388
- },
1389
- })
1390
- ?.catch?.(() => undefined);
1391
- throw errorWithConfigPaths(directory, readDecision.message);
1392
- }
1355
+ if (readDecision.status === 'deny') reportBlocked(readDecision);
1393
1356
 
1394
1357
  const writeDecision = evaluateWritePermission(
1395
1358
  path,
@@ -1397,44 +1360,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1397
1360
  directory,
1398
1361
  effectiveAllowWrite,
1399
1362
  );
1400
- if (writeDecision.status === 'deny') {
1401
- client.tui
1402
- ?.showToast?.({
1403
- body: {
1404
- title: 'Sandbox blocked',
1405
- message: writeDecision.message.slice(0, 120),
1406
- variant: 'error',
1407
- },
1408
- })
1409
- ?.catch?.(() => undefined);
1410
- throw errorWithConfigPaths(directory, writeDecision.message);
1411
- }
1363
+ if (writeDecision.status === 'deny') reportBlocked(writeDecision);
1412
1364
  }
1413
1365
 
1414
1366
  if (!config.network.allowNetwork) {
1415
- const effectiveConfig = {
1416
- ...config,
1417
- network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1418
- };
1419
- const blockedDomain = firstBlockedDomain(shellCommand, effectiveConfig);
1420
- if (blockedDomain) {
1421
- const reason =
1422
- blockedDomain.reason === 'deniedDomains'
1423
- ? 'is blocked by network.deniedDomains'
1424
- : 'is not in network.allowedDomains';
1425
- client.tui
1426
- ?.showToast?.({
1427
- body: {
1428
- title: 'Sandbox blocked',
1429
- message: `Network access denied for "${blockedDomain.domain}"`,
1430
- variant: 'error',
1431
- },
1432
- })
1433
- ?.catch?.(() => undefined);
1434
- throw errorWithConfigPaths(
1435
- directory,
1436
- `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
1437
- );
1367
+ for (const domain of extractDomainsFromCommand(shellCommand)) {
1368
+ const decision = evaluateDomainPermission(domain, config);
1369
+ if (decision.status !== 'allow') reportBlocked(decision);
1438
1370
  }
1439
1371
  }
1440
1372
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.15.15",
3
+ "version": "0.15.17",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -49,7 +49,7 @@
49
49
  "ci:test": "npm test"
50
50
  },
51
51
  "dependencies": {
52
- "@landstrip/landstrip": "^0.15.9"
52
+ "@landstrip/landstrip": "^0.15.14"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@opencode-ai/plugin": "^1.17.7",
package/shared.ts CHANGED
@@ -267,6 +267,28 @@ export function permissionPattern(permission: Record<string, unknown>): string |
267
267
  return undefined;
268
268
  }
269
269
 
270
+ /**
271
+ * Every string pattern a permission carries, in declaration order, read from
272
+ * `patterns`, `pattern`, or `resources`. The plural complement to
273
+ * {@link permissionPattern} for callers that must inspect all patterns.
274
+ */
275
+ export function permissionPatterns(permission: Record<string, unknown>): string[] {
276
+ const patterns = permission.patterns;
277
+ if (Array.isArray(patterns))
278
+ return patterns.filter((item): item is string => typeof item === 'string');
279
+
280
+ const pattern = permission.pattern;
281
+ if (typeof pattern === 'string') return [pattern];
282
+ if (Array.isArray(pattern))
283
+ return pattern.filter((item): item is string => typeof item === 'string');
284
+
285
+ const resources = permission.resources;
286
+ if (Array.isArray(resources))
287
+ return resources.filter((item): item is string => typeof item === 'string');
288
+
289
+ return [];
290
+ }
291
+
270
292
  export function permissionLabel(permission: Record<string, unknown>): string {
271
293
  const type = permissionType(permission, 'permission');
272
294
  const title = typeof permission.title === 'string' ? permission.title : type;