pi-landstrip 0.16.23 → 0.16.25

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 (2) hide show
  1. package/index.ts +104 -14
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -375,6 +375,77 @@ function normalizeBlockedPath(path: string, cwd: string): string {
375
375
  return canonicalizePath(isAbsolute(path) ? path : join(cwd, path), cwd);
376
376
  }
377
377
 
378
+ // Breadth-first filesystem approval: when the user allows a blocked read/write,
379
+ // approve the broadest reasonable ancestor (e.g. `~/.cargo`, not each subcrate
380
+ // file) so a single scan does not spawn one prompt per file. matchesPattern
381
+ // already treats a bare directory entry as covering everything beneath it, so
382
+ // storing the scope is enough for sibling files to auto-allow.
383
+ function pathUnderDirectory(filePath: string, dir: string): boolean {
384
+ if (filePath === dir) return true;
385
+ const sep = dir.endsWith('/') ? '' : '/';
386
+ return filePath.startsWith(dir + sep);
387
+ }
388
+
389
+ // The broadest ancestor worth approving in one action: the immediate child of
390
+ // `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
391
+ // for paths under it, otherwise the containing directory. When the file sits
392
+ // directly on a boundary (so the only ancestor is `$HOME` itself, which would
393
+ // over-broaden), fall back to the exact file so nothing widens silently.
394
+ export function sessionScopeFor(filePath: string, baseDirectory: string): string {
395
+ const dir = dirname(filePath);
396
+ const home = homedir();
397
+ const boundaries = new Set<string>();
398
+ if (home) boundaries.add(home);
399
+ try {
400
+ const realHome = realpathSync.native(home);
401
+ if (realHome) boundaries.add(realHome);
402
+ } catch {
403
+ // $HOME not resolvable — fall back to the raw value only.
404
+ }
405
+
406
+ for (const boundary of boundaries) {
407
+ if (pathUnderDirectory(dir, boundary)) {
408
+ const rest = dir.slice(boundary.length).replace(/^\/+/, '');
409
+ const first = rest.split('/')[0];
410
+ if (!first) return filePath;
411
+ return boundary.endsWith('/') ? boundary + first : `${boundary}/${first}`;
412
+ }
413
+ }
414
+
415
+ if (pathUnderDirectory(dir, baseDirectory)) return baseDirectory;
416
+ return dir;
417
+ }
418
+
419
+ // Length of the longest entry in `patterns` that matches `path`, or -1 for no
420
+ // match. Canonicalized so the value reflects how specific the rule is.
421
+ function longestPrefixMatch(path: string, patterns: string[], cwd: string): number {
422
+ let best = -1;
423
+ for (const pattern of patterns) {
424
+ if (!matchesPattern(path, [pattern], cwd)) continue;
425
+ const canonical = pattern.includes('*')
426
+ ? expandPath(pattern, cwd)
427
+ : canonicalizePath(pattern, cwd);
428
+ if (canonical.length > best) best = canonical.length;
429
+ }
430
+ return best;
431
+ }
432
+
433
+ // Most-specific-match wins: a read is allowed when its longest matching
434
+ // allowRead entry is at least as specific as its longest matching denyRead
435
+ // entry. So an explicit allow (e.g. a granted `~/.cache`) overrides the broad
436
+ // `denyRead` gate (`/home`), while a narrow denyRead carve-out still beats a
437
+ // broad allow. Ties favor allow. denyWrite stays an absolute block elsewhere.
438
+ export function readAllowed(
439
+ path: string,
440
+ allowRead: string[],
441
+ denyRead: string[],
442
+ cwd: string,
443
+ ): boolean {
444
+ const allow = longestPrefixMatch(path, allowRead, cwd);
445
+ if (allow < 0) return false;
446
+ return allow >= longestPrefixMatch(path, denyRead, cwd);
447
+ }
448
+
378
449
  function isPathLike(value: string): boolean {
379
450
  const trimmed = value.trim();
380
451
  return (
@@ -933,26 +1004,46 @@ export function createLandstripIntegration(
933
1004
  if (choice === 'global') await addDomainToConfig(globalPath, domain);
934
1005
  }
935
1006
 
1007
+ // Breadth-first: approve the broadest reasonable ancestor of the blocked file
1008
+ // (see sessionScopeFor) instead of the exact path, so the still-running
1009
+ // command stops prompting for sibling files under the same tree. When the
1010
+ // scope widens beyond the file, tell the user what was actually granted.
1011
+ function noteScope(
1012
+ ctx: ExtensionContext,
1013
+ verb: string,
1014
+ choice: Exclude<PermissionChoice, 'abort'>,
1015
+ filePath: string,
1016
+ scope: string,
1017
+ ): void {
1018
+ if (scope !== filePath) notify(ctx, `${verb} allowed (${choice}): ${scope}`, 'info');
1019
+ }
1020
+
936
1021
  async function applyReadChoice(
1022
+ ctx: ExtensionContext,
937
1023
  choice: Exclude<PermissionChoice, 'abort'>,
938
1024
  filePath: string,
939
1025
  cwd: string,
940
1026
  ): Promise<void> {
941
1027
  const { globalPath, projectPath } = getConfigPaths(cwd);
942
- if (!sessionAllowedReadPaths.includes(filePath)) sessionAllowedReadPaths.push(filePath);
943
- if (choice === 'project') await addReadPathToConfig(projectPath, filePath);
944
- if (choice === 'global') await addReadPathToConfig(globalPath, filePath);
1028
+ const scope = sessionScopeFor(filePath, cwd);
1029
+ if (!sessionAllowedReadPaths.includes(scope)) sessionAllowedReadPaths.push(scope);
1030
+ if (choice === 'project') await addReadPathToConfig(projectPath, scope);
1031
+ if (choice === 'global') await addReadPathToConfig(globalPath, scope);
1032
+ noteScope(ctx, 'Read', choice, filePath, scope);
945
1033
  }
946
1034
 
947
1035
  async function applyWriteChoice(
1036
+ ctx: ExtensionContext,
948
1037
  choice: Exclude<PermissionChoice, 'abort'>,
949
1038
  filePath: string,
950
1039
  cwd: string,
951
1040
  ): Promise<void> {
952
1041
  const { globalPath, projectPath } = getConfigPaths(cwd);
953
- if (!sessionAllowedWritePaths.includes(filePath)) sessionAllowedWritePaths.push(filePath);
954
- if (choice === 'project') await addWritePathToConfig(projectPath, filePath);
955
- if (choice === 'global') await addWritePathToConfig(globalPath, filePath);
1042
+ const scope = sessionScopeFor(filePath, cwd);
1043
+ if (!sessionAllowedWritePaths.includes(scope)) sessionAllowedWritePaths.push(scope);
1044
+ if (choice === 'project') await addWritePathToConfig(projectPath, scope);
1045
+ if (choice === 'global') await addWritePathToConfig(globalPath, scope);
1046
+ noteScope(ctx, 'Write', choice, filePath, scope);
956
1047
  }
957
1048
 
958
1049
  async function ensureDomainAllowed(
@@ -1282,8 +1373,7 @@ export function createLandstripIntegration(
1282
1373
  const config = loadConfig(cwd);
1283
1374
  const isAllowed = (cfg: SandboxConfig): boolean =>
1284
1375
  operation === 'read'
1285
- ? !matchesPattern(path, cfg.filesystem.denyRead, cwd) &&
1286
- matchesPattern(path, getEffectiveAllowRead(cfg), cwd)
1376
+ ? readAllowed(path, getEffectiveAllowRead(cfg), cfg.filesystem.denyRead, cwd)
1287
1377
  : !matchesPattern(path, cfg.filesystem.denyWrite, cwd) &&
1288
1378
  !shouldPromptForWrite(path, getEffectiveAllowWrite(cfg), cwd);
1289
1379
 
@@ -1325,8 +1415,8 @@ export function createLandstripIntegration(
1325
1415
  respondQuery(queryId, 'deny');
1326
1416
  return;
1327
1417
  }
1328
- if (operation === 'read') await applyReadChoice(choice, path, cwd);
1329
- else await applyWriteChoice(choice, path, cwd);
1418
+ if (operation === 'read') await applyReadChoice(ctx, choice, path, cwd);
1419
+ else await applyWriteChoice(ctx, choice, path, cwd);
1330
1420
  respondQuery(queryId, 'allow');
1331
1421
  })
1332
1422
  .catch(() => respondQuery(queryId, 'deny'));
@@ -1447,7 +1537,7 @@ export function createLandstripIntegration(
1447
1537
  if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config), ctx.cwd)) {
1448
1538
  const choice = await promptWriteBlock(ctx, blockedPath);
1449
1539
  if (choice === 'abort') return null;
1450
- await applyWriteChoice(choice, blockedPath, ctx.cwd);
1540
+ await applyWriteChoice(ctx, choice, blockedPath, ctx.cwd);
1451
1541
  }
1452
1542
 
1453
1543
  config = loadConfig(ctx.cwd);
@@ -1486,7 +1576,7 @@ export function createLandstripIntegration(
1486
1576
  : undefined,
1487
1577
  );
1488
1578
  if (choice === 'abort') return null;
1489
- await applyReadChoice(choice, blockedPath, ctx.cwd);
1579
+ await applyReadChoice(ctx, choice, blockedPath, ctx.cwd);
1490
1580
  }
1491
1581
 
1492
1582
  onUpdate?.({
@@ -1732,7 +1822,7 @@ export function createLandstripIntegration(
1732
1822
  reason: `Sandbox: read access denied for "${filePath}"`,
1733
1823
  };
1734
1824
  }
1735
- await applyReadChoice(choice, filePath, ctx.cwd);
1825
+ await applyReadChoice(ctx, choice, filePath, ctx.cwd);
1736
1826
  }
1737
1827
  }
1738
1828
 
@@ -1756,7 +1846,7 @@ export function createLandstripIntegration(
1756
1846
  reason: `Sandbox: write access denied for "${filePath}" (not in allowWrite)`,
1757
1847
  };
1758
1848
  }
1759
- await applyWriteChoice(choice, filePath, ctx.cwd);
1849
+ await applyWriteChoice(ctx, choice, filePath, ctx.cwd);
1760
1850
  }
1761
1851
  }
1762
1852
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.16.23",
3
+ "version": "0.16.25",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@earendil-works/pi-tui": "^0.78.0",
36
- "@landstrip/landstrip": "^0.16.19"
36
+ "@landstrip/landstrip": "^0.16.20"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.74.2",