opencode-landstrip 0.15.14 → 0.15.16

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/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
 
@@ -269,23 +271,6 @@ function isBlockedByDenyRead(path: string, config: SandboxConfig, baseDirectory:
269
271
  return matchesPattern(path, config.filesystem.denyRead, baseDirectory);
270
272
  }
271
273
 
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
274
  function evaluateReadPermission(
290
275
  path: string,
291
276
  config: SandboxConfig,
@@ -754,27 +739,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
754
739
  const optionOverrides = normalizeOptions(options);
755
740
  const activeBash = new Map<string, BashSandboxState>();
756
741
  const notified = new Set<string>();
757
- const sessionAllowedReadPaths: string[] = [];
758
- const sessionAllowedWritePaths: string[] = [];
759
- const sessionAllowedDomains: string[] = [];
760
742
  const callAllowances = new Set<string>();
761
743
  let enabledNotified = false;
762
744
  let sandboxDisabled = false;
763
745
  let configuredShell: string | undefined;
764
746
  let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
765
747
 
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
748
  function allowanceKey(callID: string, kind: SandboxPermissionKind, resource: string): string {
779
749
  return `${callID}:${kind}:${resource}`;
780
750
  }
@@ -791,8 +761,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
791
761
  return callAllowances.has(allowanceKey(callID, decision.kind, decision.resource));
792
762
  }
793
763
 
794
- function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
795
- if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
764
+ function reportBlocked(decision: SandboxPermissionDecision): never {
796
765
  client.tui
797
766
  ?.showToast?.({
798
767
  body: {
@@ -805,6 +774,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
805
774
  throw errorWithConfigPaths(directory, decision.message);
806
775
  }
807
776
 
777
+ function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
778
+ if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
779
+ reportBlocked(decision);
780
+ }
781
+
808
782
  function pushCommandText(
809
783
  input: { sessionID: string },
810
784
  output: { parts: unknown[] },
@@ -822,11 +796,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
822
796
  function sandboxSummary(config: SandboxConfig): string {
823
797
  const { globalPath, projectPath } = getConfigPaths(directory);
824
798
  const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
825
- const allowed = getEffectiveAllowedDomains(config).join(', ') || '(none)';
799
+ const allowed = config.network.allowedDomains.join(', ') || '(none)';
826
800
  const denied = config.network.deniedDomains.join(', ') || '(none)';
827
801
  const denyRead = config.filesystem.denyRead.join(', ') || '(none)';
828
- const allowRead = getEffectiveAllowRead(config).join(', ') || '(none)';
829
- const allowWrite = getEffectiveAllowWrite(config).join(', ') || '(none)';
802
+ const allowRead = config.filesystem.allowRead.join(', ') || '(none)';
803
+ const allowWrite = config.filesystem.allowWrite.join(', ') || '(none)';
830
804
  const denyWrite = config.filesystem.denyWrite.join(', ') || '(none)';
831
805
 
832
806
  return [
@@ -1050,7 +1024,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1050
1024
  const callAllowedDomains: string[] = [];
1051
1025
  const effectiveConfig = {
1052
1026
  ...config,
1053
- network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1027
+ network: { ...config.network },
1054
1028
  };
1055
1029
 
1056
1030
  if (!allowNetwork) {
@@ -1114,14 +1088,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1114
1088
  if (!config) return;
1115
1089
 
1116
1090
  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
- : '';
1091
+ const permission = permissionType(request);
1125
1092
  const metadata = isRecord(request.metadata) ? request.metadata : {};
1126
1093
  const tool = isRecord(request.tool) ? request.tool : undefined;
1127
1094
  const callID =
@@ -1130,17 +1097,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1130
1097
  : typeof tool?.callID === 'string'
1131
1098
  ? tool.callID
1132
1099
  : 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
- : [];
1100
+ const patterns = permissionPatterns(request);
1140
1101
 
1141
1102
  const decisions: SandboxPermissionDecision[] = [];
1142
- const effectiveAllowRead = getEffectiveAllowRead(config);
1143
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1103
+ const effectiveAllowRead = config.filesystem.allowRead;
1104
+ const effectiveAllowWrite = config.filesystem.allowWrite;
1144
1105
 
1145
1106
  if (permission === 'read') {
1146
1107
  for (const pattern of patterns) {
@@ -1189,8 +1150,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1189
1150
  const config = await activeConfig();
1190
1151
  if (!config) return;
1191
1152
 
1192
- const effectiveAllowRead = getEffectiveAllowRead(config);
1193
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1153
+ const effectiveAllowRead = config.filesystem.allowRead;
1154
+ const effectiveAllowWrite = config.filesystem.allowWrite;
1194
1155
 
1195
1156
  if (input.tool === 'bash') {
1196
1157
  await prepareBash(input.callID, output.args, config);
@@ -1373,23 +1334,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1373
1334
  const config = await activeConfig();
1374
1335
  if (!config) return;
1375
1336
 
1376
- const effectiveAllowRead = getEffectiveAllowRead(config);
1377
- const effectiveAllowWrite = getEffectiveAllowWrite(config);
1337
+ const effectiveAllowRead = config.filesystem.allowRead;
1338
+ const effectiveAllowWrite = config.filesystem.allowWrite;
1378
1339
 
1379
1340
  for (const path of extractCandidatePaths(shellCommand)) {
1380
1341
  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
- }
1342
+ if (readDecision.status === 'deny') reportBlocked(readDecision);
1393
1343
 
1394
1344
  const writeDecision = evaluateWritePermission(
1395
1345
  path,
@@ -1397,44 +1347,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1397
1347
  directory,
1398
1348
  effectiveAllowWrite,
1399
1349
  );
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
- }
1350
+ if (writeDecision.status === 'deny') reportBlocked(writeDecision);
1412
1351
  }
1413
1352
 
1414
1353
  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
- );
1354
+ for (const domain of extractDomainsFromCommand(shellCommand)) {
1355
+ const decision = evaluateDomainPermission(domain, config);
1356
+ if (decision.status !== 'allow') reportBlocked(decision);
1438
1357
  }
1439
1358
  }
1440
1359
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.15.14",
3
+ "version": "0.15.16",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
package/sandbox.json CHANGED
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "filesystem": {
12
12
  "denyRead": ["/Users", "/home"],
13
- "allowRead": [".", "~/.gitconfig", "/dev/null"],
13
+ "allowRead": [".", "~/.gitconfig", "~/.config/git/config", "/dev/null"],
14
14
  "allowWrite": [".", "/dev/null"],
15
15
  "denyWrite": ["**/.env", "**/.env.*", "**/*.pem", "**/*.key"]
16
16
  }
package/shared.ts CHANGED
@@ -48,7 +48,7 @@ export const DEFAULT_CONFIG: SandboxConfig = {
48
48
  },
49
49
  filesystem: {
50
50
  denyRead: ['/Users', '/home'],
51
- allowRead: ['.', '~/.gitconfig', '/dev/null'],
51
+ allowRead: ['.', '~/.gitconfig', '~/.config/git/config', '/dev/null'],
52
52
  allowWrite: ['.', '/dev/null'],
53
53
  denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
54
54
  },
@@ -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;