opencode-landstrip 0.3.10 → 0.3.11

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 +262 -103
  2. package/landstrip.d.ts +3 -0
  3. package/package.json +8 -7
package/index.ts CHANGED
@@ -74,6 +74,15 @@ interface BashSandboxState {
74
74
  stop: (() => Promise<void>) | null;
75
75
  }
76
76
 
77
+ type SandboxPermissionKind = 'read' | 'write' | 'domain';
78
+
79
+ interface SandboxPermissionDecision {
80
+ status: 'allow' | 'ask' | 'deny';
81
+ kind: SandboxPermissionKind;
82
+ resource: string;
83
+ message: string;
84
+ }
85
+
77
86
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
78
87
 
79
88
  const LANDSTRIP_VERSION = [0, 11, 0] as const;
@@ -434,6 +443,89 @@ function firstBlockedDomain(
434
443
  return null;
435
444
  }
436
445
 
446
+ function evaluateReadPermission(
447
+ path: string,
448
+ config: SandboxConfig,
449
+ baseDirectory: string,
450
+ effectiveAllowRead: string[],
451
+ ): SandboxPermissionDecision {
452
+ const filePath = canonicalizePath(path, baseDirectory);
453
+
454
+ if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
455
+ return { status: 'allow', kind: 'read', resource: filePath, message: '' };
456
+ }
457
+
458
+ if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
459
+ return {
460
+ status: 'deny',
461
+ kind: 'read',
462
+ resource: filePath,
463
+ message: `Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
464
+ };
465
+ }
466
+
467
+ return {
468
+ status: 'ask',
469
+ kind: 'read',
470
+ resource: filePath,
471
+ message: `Sandbox: read access requires approval for "${filePath}" (not in filesystem.allowRead).`,
472
+ };
473
+ }
474
+
475
+ function evaluateWritePermission(
476
+ path: string,
477
+ config: SandboxConfig,
478
+ baseDirectory: string,
479
+ effectiveAllowWrite: string[],
480
+ ): SandboxPermissionDecision {
481
+ const filePath = canonicalizePath(path, baseDirectory);
482
+
483
+ if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
484
+ return { status: 'allow', kind: 'write', resource: filePath, message: '' };
485
+ }
486
+
487
+ if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
488
+ return {
489
+ status: 'deny',
490
+ kind: 'write',
491
+ resource: filePath,
492
+ message: `Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
493
+ };
494
+ }
495
+
496
+ return {
497
+ status: 'ask',
498
+ kind: 'write',
499
+ resource: filePath,
500
+ message: `Sandbox: write access requires approval for "${filePath}" (not in filesystem.allowWrite).`,
501
+ };
502
+ }
503
+
504
+ function evaluateDomainPermission(
505
+ domain: string,
506
+ config: SandboxConfig,
507
+ ): SandboxPermissionDecision {
508
+ if (config.network.allowNetwork || domainMatchesAny(domain, config.network.allowedDomains)) {
509
+ return { status: 'allow', kind: 'domain', resource: domain, message: '' };
510
+ }
511
+
512
+ if (domainMatchesAny(domain, config.network.deniedDomains)) {
513
+ return {
514
+ status: 'deny',
515
+ kind: 'domain',
516
+ resource: domain,
517
+ message: `Sandbox: network access denied for "${domain}" (is blocked by network.deniedDomains).`,
518
+ };
519
+ }
520
+
521
+ return {
522
+ status: 'ask',
523
+ kind: 'domain',
524
+ resource: domain,
525
+ message: `Sandbox: network access requires approval for "${domain}" (not in network.allowedDomains).`,
526
+ };
527
+ }
528
+
437
529
  function landstripVersion(): string | null {
438
530
  const result = spawnSync(binaryPath(), ['--version'], { encoding: 'utf-8' });
439
531
  if (result.status !== 0) return null;
@@ -829,69 +921,14 @@ function errorWithConfigPaths(baseDirectory: string, message: string): Error {
829
921
  return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
830
922
  }
831
923
 
832
- function assertReadAllowed(
833
- path: string,
834
- config: SandboxConfig,
835
- baseDirectory: string,
836
- effectiveAllowRead: string[],
837
- ): void {
838
- const filePath = canonicalizePath(path, baseDirectory);
839
-
840
- if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) return;
841
-
842
- if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
843
- throw errorWithConfigPaths(
844
- baseDirectory,
845
- `Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
846
- );
847
- }
848
-
849
- throw errorWithConfigPaths(
850
- baseDirectory,
851
- `Sandbox: read access denied for "${filePath}" (not in filesystem.allowRead).`,
852
- );
853
- }
854
-
855
- function assertWriteAllowed(
856
- path: string,
857
- config: SandboxConfig,
858
- baseDirectory: string,
859
- effectiveAllowWrite: string[],
860
- ): void {
861
- const filePath = canonicalizePath(path, baseDirectory);
862
-
863
- if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) return;
864
-
865
- if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
866
- throw errorWithConfigPaths(
867
- baseDirectory,
868
- `Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
869
- );
870
- }
871
-
872
- throw errorWithConfigPaths(
873
- baseDirectory,
874
- `Sandbox: write access denied for "${filePath}" (not in filesystem.allowWrite).`,
875
- );
876
- }
877
-
878
- function assertApplyPatchAllowed(
879
- args: Record<string, unknown>,
880
- config: SandboxConfig,
881
- baseDirectory: string,
882
- effectiveAllowWrite: string[],
883
- ): void {
884
- if (typeof args.patchText !== 'string') return;
885
- for (const path of extractPatchPaths(args.patchText))
886
- assertWriteAllowed(path, config, baseDirectory, effectiveAllowWrite);
887
- }
888
-
889
924
  const plugin: Plugin = async ({ client, directory }: PluginInput, options?: PluginOptions) => {
890
925
  const optionOverrides = normalizeOptions(options);
891
926
  const activeBash = new Map<string, BashSandboxState>();
892
927
  const notified = new Set<string>();
893
928
  const sessionAllowedReadPaths: string[] = [];
894
929
  const sessionAllowedWritePaths: string[] = [];
930
+ const sessionAllowedDomains: string[] = [];
931
+ const callAllowances = new Set<string>();
895
932
  let enabledNotified = false;
896
933
  let sandboxDisabled = false;
897
934
  let configuredShell: string | undefined;
@@ -905,6 +942,31 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
905
942
  return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
906
943
  }
907
944
 
945
+ function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
946
+ return [...config.network.allowedDomains, ...sessionAllowedDomains];
947
+ }
948
+
949
+ function allowanceKey(callID: string, kind: SandboxPermissionKind, resource: string): string {
950
+ return `${callID}:${kind}:${resource}`;
951
+ }
952
+
953
+ function rememberCallAllowance(
954
+ callID: string | undefined,
955
+ decision: SandboxPermissionDecision,
956
+ ): void {
957
+ if (!callID || decision.status === 'deny') return;
958
+ callAllowances.add(allowanceKey(callID, decision.kind, decision.resource));
959
+ }
960
+
961
+ function hasCallAllowance(callID: string, decision: SandboxPermissionDecision): boolean {
962
+ return callAllowances.has(allowanceKey(callID, decision.kind, decision.resource));
963
+ }
964
+
965
+ function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
966
+ if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
967
+ throw errorWithConfigPaths(directory, decision.message);
968
+ }
969
+
908
970
  function pushCommandText(
909
971
  input: { sessionID: string },
910
972
  output: { parts: unknown[] },
@@ -922,7 +984,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
922
984
  function sandboxSummary(config: SandboxConfig): string {
923
985
  const { globalPath, projectPath } = getConfigPaths(directory);
924
986
  const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
925
- const allowed = config.network.allowedDomains.join(', ') || '(none)';
987
+ const allowed = getEffectiveAllowedDomains(config).join(', ') || '(none)';
926
988
  const denied = config.network.deniedDomains.join(', ') || '(none)';
927
989
  const denyRead = config.filesystem.denyRead.join(', ') || '(none)';
928
990
  const allowRead = getEffectiveAllowRead(config).join(', ') || '(none)';
@@ -1130,27 +1192,37 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1130
1192
  }
1131
1193
 
1132
1194
  const allowNetwork = config.network.allowNetwork;
1195
+ const callAllowedDomains: string[] = [];
1196
+ const effectiveConfig = {
1197
+ ...config,
1198
+ network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1199
+ };
1133
1200
 
1134
1201
  if (!allowNetwork) {
1135
- const blockedDomain = firstBlockedDomain(args.command as string, config);
1136
- if (blockedDomain) {
1137
- const reason =
1138
- blockedDomain.reason === 'deniedDomains'
1139
- ? 'is blocked by network.deniedDomains'
1140
- : 'is not in network.allowedDomains';
1141
- throw errorWithConfigPaths(
1142
- directory,
1143
- `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
1144
- );
1202
+ for (const domain of extractDomainsFromCommand(args.command as string)) {
1203
+ const decision = evaluateDomainPermission(domain, effectiveConfig);
1204
+ if (decision.status === 'allow') continue;
1205
+ if (decision.status === 'ask' && hasCallAllowance(callID, decision)) {
1206
+ callAllowedDomains.push(domain);
1207
+ continue;
1208
+ }
1209
+ throw errorWithConfigPaths(directory, decision.message);
1145
1210
  }
1146
1211
  }
1147
1212
 
1148
- const proxy = allowNetwork ? null : await startProxy(config);
1213
+ if (callAllowedDomains.length > 0) {
1214
+ effectiveConfig.network = {
1215
+ ...effectiveConfig.network,
1216
+ allowedDomains: [...effectiveConfig.network.allowedDomains, ...callAllowedDomains],
1217
+ };
1218
+ }
1219
+
1220
+ const proxy = allowNetwork ? null : await startProxy(effectiveConfig);
1149
1221
  const proxyPort = proxy ? proxy.port : null;
1150
1222
  let policy: { dir: string; path: string };
1151
1223
 
1152
1224
  try {
1153
- policy = writePolicyFile(config, directory, proxyPort);
1225
+ policy = writePolicyFile(effectiveConfig, directory, proxyPort);
1154
1226
  } catch (error) {
1155
1227
  if (proxy) await proxy.stop().catch(() => undefined);
1156
1228
  throw error;
@@ -1181,6 +1253,80 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1181
1253
  configuredShell = configuredShellPath(config);
1182
1254
  },
1183
1255
 
1256
+ 'permission.ask': async (input, output) => {
1257
+ const config = await activeConfig();
1258
+ if (!config) return;
1259
+
1260
+ const request = input as Record<string, unknown>;
1261
+ const permission =
1262
+ typeof request.type === 'string'
1263
+ ? request.type
1264
+ : typeof request.permission === 'string'
1265
+ ? request.permission
1266
+ : typeof request.action === 'string'
1267
+ ? request.action
1268
+ : '';
1269
+ const metadata = isRecord(request.metadata) ? request.metadata : {};
1270
+ const tool = isRecord(request.tool) ? request.tool : undefined;
1271
+ const callID =
1272
+ typeof request.callID === 'string'
1273
+ ? request.callID
1274
+ : typeof tool?.callID === 'string'
1275
+ ? tool.callID
1276
+ : undefined;
1277
+ const patterns = Array.isArray(request.patterns)
1278
+ ? request.patterns.filter((item): item is string => typeof item === 'string')
1279
+ : typeof request.pattern === 'string'
1280
+ ? [request.pattern]
1281
+ : Array.isArray(request.resources)
1282
+ ? request.resources.filter((item): item is string => typeof item === 'string')
1283
+ : [];
1284
+
1285
+ const decisions: SandboxPermissionDecision[] = [];
1286
+ const effectiveAllowRead = getEffectiveAllowRead(config);
1287
+ const effectiveAllowWrite = getEffectiveAllowWrite(config);
1288
+
1289
+ if (permission === 'read') {
1290
+ for (const pattern of patterns) {
1291
+ decisions.push(evaluateReadPermission(pattern, config, directory, effectiveAllowRead));
1292
+ }
1293
+ }
1294
+
1295
+ if (permission === 'glob' || permission === 'grep' || permission === 'list') {
1296
+ const searchPath = typeof metadata.path === 'string' ? metadata.path : '.';
1297
+ decisions.push(evaluateReadPermission(searchPath, config, directory, effectiveAllowRead));
1298
+ }
1299
+
1300
+ if (permission === 'edit') {
1301
+ const filepath =
1302
+ typeof metadata.filepath === 'string'
1303
+ ? metadata.filepath
1304
+ : patterns.length === 1
1305
+ ? patterns[0]
1306
+ : undefined;
1307
+ if (filepath) {
1308
+ decisions.push(evaluateWritePermission(filepath, config, directory, effectiveAllowWrite));
1309
+ }
1310
+ }
1311
+
1312
+ if (permission === 'bash') {
1313
+ const command = typeof metadata.command === 'string' ? metadata.command : patterns[0];
1314
+ if (typeof command === 'string' && !config.network.allowNetwork) {
1315
+ for (const domain of extractDomainsFromCommand(command)) {
1316
+ decisions.push(evaluateDomainPermission(domain, config));
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ const decision =
1322
+ decisions.find((item) => item.status === 'deny') ??
1323
+ decisions.find((item) => item.status === 'ask');
1324
+ if (!decision) return;
1325
+
1326
+ output.status = decision.status;
1327
+ rememberCallAllowance(callID, decision);
1328
+ },
1329
+
1184
1330
  'tool.execute.before': async (input, output) => {
1185
1331
  if (!isRecord(output.args)) return;
1186
1332
 
@@ -1197,23 +1343,40 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1197
1343
 
1198
1344
  if (input.tool === 'read') {
1199
1345
  const path = getToolPath(output.args);
1200
- if (path) assertReadAllowed(path, config, directory, effectiveAllowRead);
1346
+ if (path)
1347
+ enforcePermission(
1348
+ input.callID,
1349
+ evaluateReadPermission(path, config, directory, effectiveAllowRead),
1350
+ );
1201
1351
  return;
1202
1352
  }
1203
1353
 
1204
1354
  if (input.tool === 'glob' || input.tool === 'grep' || input.tool === 'list') {
1205
- assertReadAllowed(getSearchPath(output.args), config, directory, effectiveAllowRead);
1355
+ enforcePermission(
1356
+ input.callID,
1357
+ evaluateReadPermission(getSearchPath(output.args), config, directory, effectiveAllowRead),
1358
+ );
1206
1359
  return;
1207
1360
  }
1208
1361
 
1209
1362
  if (input.tool === 'write' || input.tool === 'edit') {
1210
1363
  const path = getToolPath(output.args);
1211
- if (path) assertWriteAllowed(path, config, directory, effectiveAllowWrite);
1364
+ if (path)
1365
+ enforcePermission(
1366
+ input.callID,
1367
+ evaluateWritePermission(path, config, directory, effectiveAllowWrite),
1368
+ );
1212
1369
  return;
1213
1370
  }
1214
1371
 
1215
- if (input.tool === 'apply_patch')
1216
- assertApplyPatchAllowed(output.args, config, directory, effectiveAllowWrite);
1372
+ if (input.tool === 'apply_patch' && typeof output.args.patchText === 'string') {
1373
+ for (const path of extractPatchPaths(output.args.patchText)) {
1374
+ enforcePermission(
1375
+ input.callID,
1376
+ evaluateWritePermission(path, config, directory, effectiveAllowWrite),
1377
+ );
1378
+ }
1379
+ }
1217
1380
  },
1218
1381
 
1219
1382
  'shell.env': async (input, output) => {
@@ -1258,31 +1421,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1258
1421
 
1259
1422
  const blockedPath = extractBlockedWritePath(outputText, directory, state.originalCommand);
1260
1423
  if (blockedPath) {
1261
- const config = loadConfig(directory, optionOverrides);
1262
- if (
1263
- !sessionAllowedWritePaths.includes(blockedPath) &&
1264
- !matchesPattern(blockedPath, config.filesystem.allowWrite, directory) &&
1265
- !matchesPattern(blockedPath, config.filesystem.denyWrite, directory)
1266
- ) {
1267
- sessionAllowedWritePaths.push(blockedPath);
1268
- await notifyOnce(
1269
- `write-allow:${blockedPath}`,
1270
- `Write access granted for session: "${blockedPath}"`,
1271
- 'warning',
1272
- );
1273
- }
1274
- if (
1275
- !sessionAllowedReadPaths.includes(blockedPath) &&
1276
- !matchesPattern(blockedPath, config.filesystem.allowRead, directory) &&
1277
- !matchesPattern(blockedPath, config.filesystem.denyRead, directory)
1278
- ) {
1279
- sessionAllowedReadPaths.push(blockedPath);
1280
- await notifyOnce(
1281
- `read-allow:${blockedPath}`,
1282
- `Read access granted for session: "${blockedPath}"`,
1283
- 'warning',
1284
- );
1285
- }
1424
+ await notifyOnce(
1425
+ `blocked:${blockedPath}`,
1426
+ `Sandbox blocked access to "${blockedPath}". Approve the related OpenCode permission prompt and retry if needed.`,
1427
+ 'warning',
1428
+ );
1286
1429
  }
1287
1430
 
1288
1431
  await cleanupBash(input.callID);
@@ -1337,12 +1480,28 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1337
1480
  const effectiveAllowWrite = getEffectiveAllowWrite(config);
1338
1481
 
1339
1482
  for (const path of extractCandidatePaths(shellCommand)) {
1340
- assertReadAllowed(path, config, directory, effectiveAllowRead);
1341
- assertWriteAllowed(path, config, directory, effectiveAllowWrite);
1483
+ const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
1484
+ if (readDecision.status === 'deny') {
1485
+ throw errorWithConfigPaths(directory, readDecision.message);
1486
+ }
1487
+
1488
+ const writeDecision = evaluateWritePermission(
1489
+ path,
1490
+ config,
1491
+ directory,
1492
+ effectiveAllowWrite,
1493
+ );
1494
+ if (writeDecision.status === 'deny') {
1495
+ throw errorWithConfigPaths(directory, writeDecision.message);
1496
+ }
1342
1497
  }
1343
1498
 
1344
1499
  if (!config.network.allowNetwork) {
1345
- const blockedDomain = firstBlockedDomain(shellCommand, config);
1500
+ const effectiveConfig = {
1501
+ ...config,
1502
+ network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
1503
+ };
1504
+ const blockedDomain = firstBlockedDomain(shellCommand, effectiveConfig);
1346
1505
  if (blockedDomain) {
1347
1506
  const reason =
1348
1507
  blockedDomain.reason === 'deniedDomains'
package/landstrip.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ declare module '@jarkkojs/landstrip' {
2
+ function binaryPath(): string;
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "index.ts",
18
18
  "tui.ts",
19
+ "landstrip.d.ts",
19
20
  "README.md",
20
21
  "sandbox.json"
21
22
  ],
@@ -50,17 +51,17 @@
50
51
  "@jarkkojs/landstrip": "^0.11.0"
51
52
  },
52
53
  "devDependencies": {
53
- "@opencode-ai/plugin": "^1.16.2",
54
- "@opentui/core": ">=0.3.2",
55
- "@opentui/keymap": ">=0.3.2",
56
- "@opentui/solid": ">=0.3.2",
54
+ "@opencode-ai/plugin": "^1.17.3",
55
+ "@opentui/core": ">=0.3.4",
56
+ "@opentui/keymap": ">=0.3.4",
57
+ "@opentui/solid": ">=0.3.4",
57
58
  "@types/node": "^24.0.0",
58
59
  "oxfmt": "^0.53.0",
59
60
  "oxlint": "^1.68.0",
60
61
  "typescript": "^5.8.2"
61
62
  },
62
63
  "peerDependencies": {
63
- "@opencode-ai/plugin": "^1.16.2"
64
+ "@opencode-ai/plugin": "^1.17.3"
64
65
  },
65
66
  "peerDependenciesMeta": {
66
67
  "@opencode-ai/plugin": {
@@ -68,6 +69,6 @@
68
69
  }
69
70
  },
70
71
  "engines": {
71
- "opencode": ">=1.16.2"
72
+ "opencode": ">=1.17.3"
72
73
  }
73
74
  }