pi-landstrip 0.16.22 → 0.16.24

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 +73 -12
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -375,6 +375,47 @@ 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
+
378
419
  function isPathLike(value: string): boolean {
379
420
  const trimmed = value.trim();
380
421
  return (
@@ -933,26 +974,46 @@ export function createLandstripIntegration(
933
974
  if (choice === 'global') await addDomainToConfig(globalPath, domain);
934
975
  }
935
976
 
977
+ // Breadth-first: approve the broadest reasonable ancestor of the blocked file
978
+ // (see sessionScopeFor) instead of the exact path, so the still-running
979
+ // command stops prompting for sibling files under the same tree. When the
980
+ // scope widens beyond the file, tell the user what was actually granted.
981
+ function noteScope(
982
+ ctx: ExtensionContext,
983
+ verb: string,
984
+ choice: Exclude<PermissionChoice, 'abort'>,
985
+ filePath: string,
986
+ scope: string,
987
+ ): void {
988
+ if (scope !== filePath) notify(ctx, `${verb} allowed (${choice}): ${scope}`, 'info');
989
+ }
990
+
936
991
  async function applyReadChoice(
992
+ ctx: ExtensionContext,
937
993
  choice: Exclude<PermissionChoice, 'abort'>,
938
994
  filePath: string,
939
995
  cwd: string,
940
996
  ): Promise<void> {
941
997
  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);
998
+ const scope = sessionScopeFor(filePath, cwd);
999
+ if (!sessionAllowedReadPaths.includes(scope)) sessionAllowedReadPaths.push(scope);
1000
+ if (choice === 'project') await addReadPathToConfig(projectPath, scope);
1001
+ if (choice === 'global') await addReadPathToConfig(globalPath, scope);
1002
+ noteScope(ctx, 'Read', choice, filePath, scope);
945
1003
  }
946
1004
 
947
1005
  async function applyWriteChoice(
1006
+ ctx: ExtensionContext,
948
1007
  choice: Exclude<PermissionChoice, 'abort'>,
949
1008
  filePath: string,
950
1009
  cwd: string,
951
1010
  ): Promise<void> {
952
1011
  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);
1012
+ const scope = sessionScopeFor(filePath, cwd);
1013
+ if (!sessionAllowedWritePaths.includes(scope)) sessionAllowedWritePaths.push(scope);
1014
+ if (choice === 'project') await addWritePathToConfig(projectPath, scope);
1015
+ if (choice === 'global') await addWritePathToConfig(globalPath, scope);
1016
+ noteScope(ctx, 'Write', choice, filePath, scope);
956
1017
  }
957
1018
 
958
1019
  async function ensureDomainAllowed(
@@ -1325,8 +1386,8 @@ export function createLandstripIntegration(
1325
1386
  respondQuery(queryId, 'deny');
1326
1387
  return;
1327
1388
  }
1328
- if (operation === 'read') await applyReadChoice(choice, path, cwd);
1329
- else await applyWriteChoice(choice, path, cwd);
1389
+ if (operation === 'read') await applyReadChoice(ctx, choice, path, cwd);
1390
+ else await applyWriteChoice(ctx, choice, path, cwd);
1330
1391
  respondQuery(queryId, 'allow');
1331
1392
  })
1332
1393
  .catch(() => respondQuery(queryId, 'deny'));
@@ -1447,7 +1508,7 @@ export function createLandstripIntegration(
1447
1508
  if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config), ctx.cwd)) {
1448
1509
  const choice = await promptWriteBlock(ctx, blockedPath);
1449
1510
  if (choice === 'abort') return null;
1450
- await applyWriteChoice(choice, blockedPath, ctx.cwd);
1511
+ await applyWriteChoice(ctx, choice, blockedPath, ctx.cwd);
1451
1512
  }
1452
1513
 
1453
1514
  config = loadConfig(ctx.cwd);
@@ -1486,7 +1547,7 @@ export function createLandstripIntegration(
1486
1547
  : undefined,
1487
1548
  );
1488
1549
  if (choice === 'abort') return null;
1489
- await applyReadChoice(choice, blockedPath, ctx.cwd);
1550
+ await applyReadChoice(ctx, choice, blockedPath, ctx.cwd);
1490
1551
  }
1491
1552
 
1492
1553
  onUpdate?.({
@@ -1732,7 +1793,7 @@ export function createLandstripIntegration(
1732
1793
  reason: `Sandbox: read access denied for "${filePath}"`,
1733
1794
  };
1734
1795
  }
1735
- await applyReadChoice(choice, filePath, ctx.cwd);
1796
+ await applyReadChoice(ctx, choice, filePath, ctx.cwd);
1736
1797
  }
1737
1798
  }
1738
1799
 
@@ -1756,7 +1817,7 @@ export function createLandstripIntegration(
1756
1817
  reason: `Sandbox: write access denied for "${filePath}" (not in allowWrite)`,
1757
1818
  };
1758
1819
  }
1759
- await applyWriteChoice(choice, filePath, ctx.cwd);
1820
+ await applyWriteChoice(ctx, choice, filePath, ctx.cwd);
1760
1821
  }
1761
1822
  }
1762
1823
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.16.22",
3
+ "version": "0.16.24",
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",