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.
- package/index.ts +104 -14
- 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
|
-
|
|
943
|
-
if (
|
|
944
|
-
if (choice === '
|
|
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
|
-
|
|
954
|
-
if (
|
|
955
|
-
if (choice === '
|
|
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
|
-
?
|
|
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.
|
|
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.
|
|
36
|
+
"@landstrip/landstrip": "^0.16.20"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.74.2",
|