pi-landstrip 0.12.0 → 0.14.0
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/README.md +3 -3
- package/index.ts +94 -98
- package/landstrip.d.ts +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|

|
|
4
4
|
|
|
5
5
|
Landlock-based sandboxing for [pi](https://pi.dev/) using
|
|
6
|
-
[`landstrip`](https://github.com/
|
|
6
|
+
[`landstrip`](https://github.com/landstrip/landstrip).
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@ Landlock-based sandboxing for [pi](https://pi.dev/) using
|
|
|
11
11
|
pi install npm:pi-landstrip
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
This installs `pi-landstrip` and its `@
|
|
14
|
+
This installs `pi-landstrip` and its `@landstrip/landstrip` dependency, which
|
|
15
15
|
includes platform-specific native binaries for Linux, macOS, and Windows.
|
|
16
16
|
|
|
17
17
|
On unsupported platforms the extension loads but leaves sandboxing disabled.
|
|
@@ -43,5 +43,5 @@ Use `/sandbox` inside Pi to show the active config and toggle sandboxing.
|
|
|
43
43
|
`pi-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
|
|
44
44
|
information.
|
|
45
45
|
|
|
46
|
-
The bundled `@
|
|
46
|
+
The bundled `@landstrip/landstrip` package is licensed under
|
|
47
47
|
`Apache-2.0 AND LGPL-2.1-or-later`.
|
package/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
ExtensionContext,
|
|
13
13
|
} from '@earendil-works/pi-coding-agent';
|
|
14
14
|
|
|
15
|
-
import { binaryPath } from '@
|
|
15
|
+
import { binaryPath } from '@landstrip/landstrip';
|
|
16
16
|
|
|
17
17
|
import { spawn, spawnSync } from 'node:child_process';
|
|
18
18
|
import {
|
|
@@ -76,42 +76,22 @@ interface LandstripPolicy {
|
|
|
76
76
|
filesystem: SandboxFilesystemConfig;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
type LandstripErrorReason = 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
|
|
80
79
|
type LandstripOperation = 'read' | 'write';
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
type?: LandstripErrorType;
|
|
89
|
-
source?: string;
|
|
90
|
-
mechanism?: string;
|
|
91
|
-
}
|
|
80
|
+
|
|
81
|
+
type LandstripTrap =
|
|
82
|
+
| { kind: 'filesystem'; operation: LandstripOperation; file: string; mechanism: string }
|
|
83
|
+
| { kind: 'network'; operation: string; target: string; mechanism: string }
|
|
84
|
+
| { kind: 'launch'; program: string; source: string }
|
|
85
|
+
| { kind: 'usage'; message: string }
|
|
86
|
+
| { kind: 'internal'; detail: Record<string, string> };
|
|
92
87
|
|
|
93
88
|
interface LandstripBashCallbacks {
|
|
94
89
|
onStderr?: (data: Buffer) => void;
|
|
95
90
|
onErrorFd?: (data: Buffer) => void;
|
|
96
91
|
}
|
|
97
92
|
|
|
98
|
-
const LANDSTRIP_VERSION = [0,
|
|
93
|
+
const LANDSTRIP_VERSION = [0, 14, 2] as const;
|
|
99
94
|
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
100
|
-
const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorReason>([
|
|
101
|
-
'Other',
|
|
102
|
-
'AccessDenied',
|
|
103
|
-
'LaunchFailed',
|
|
104
|
-
'SetupFailed',
|
|
105
|
-
'Usage',
|
|
106
|
-
]);
|
|
107
|
-
const LANDSTRIP_OPERATIONS = new Set<LandstripOperation>(['read', 'write']);
|
|
108
|
-
const LANDSTRIP_ERROR_TYPES = new Set<LandstripErrorType>([
|
|
109
|
-
'filesystem',
|
|
110
|
-
'network',
|
|
111
|
-
'platform',
|
|
112
|
-
'launch',
|
|
113
|
-
'encoding',
|
|
114
|
-
]);
|
|
115
95
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
116
96
|
|
|
117
97
|
const DEFAULT_CONFIG: SandboxConfig = {
|
|
@@ -405,20 +385,10 @@ function normalizePathMatch(value: string, cwd: string): string | null {
|
|
|
405
385
|
return isPathLike(value) ? normalizeBlockedPath(value, cwd) : null;
|
|
406
386
|
}
|
|
407
387
|
|
|
408
|
-
|
|
409
|
-
return error.reason === 'AccessDenied' && error.type === 'filesystem';
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function isLandstripErrorReason(value: string): value is LandstripErrorReason {
|
|
413
|
-
return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorReason);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function isLandstripOperation(value: string): value is LandstripOperation {
|
|
417
|
-
return LANDSTRIP_OPERATIONS.has(value as LandstripOperation);
|
|
418
|
-
}
|
|
388
|
+
type LandstripFilesystemTrap = Extract<LandstripTrap, { kind: 'filesystem' }>;
|
|
419
389
|
|
|
420
|
-
function
|
|
421
|
-
return
|
|
390
|
+
function isFilesystemTrap(trap: LandstripTrap): trap is LandstripFilesystemTrap {
|
|
391
|
+
return trap.kind === 'filesystem';
|
|
422
392
|
}
|
|
423
393
|
|
|
424
394
|
function extractCandidatePaths(command: string): string[] {
|
|
@@ -435,9 +405,9 @@ function extractCandidatePaths(command: string): string[] {
|
|
|
435
405
|
}
|
|
436
406
|
|
|
437
407
|
function extractBlockedPath(output: string, cwd: string, command?: string): string | null {
|
|
438
|
-
const landstripErrors =
|
|
439
|
-
|
|
440
|
-
|
|
408
|
+
const landstripErrors = parseLandstripTraps(output).filter(isFilesystemTrap);
|
|
409
|
+
if (landstripErrors.length > 0) {
|
|
410
|
+
return normalizeBlockedPath(landstripErrors[0].file, cwd);
|
|
441
411
|
}
|
|
442
412
|
|
|
443
413
|
// If landstrip reported an error but without a file field, try to
|
|
@@ -503,8 +473,8 @@ function extractNativeWriteDeniedPath(output: string, cwd: string): string | nul
|
|
|
503
473
|
}
|
|
504
474
|
|
|
505
475
|
function extractBlockedWritePath(output: string, cwd: string): string | null {
|
|
506
|
-
for (const error of
|
|
507
|
-
if (error.
|
|
476
|
+
for (const error of parseLandstripTraps(output).filter(isFilesystemTrap)) {
|
|
477
|
+
if (error.operation === 'write') {
|
|
508
478
|
return normalizeBlockedPath(error.file, cwd);
|
|
509
479
|
}
|
|
510
480
|
}
|
|
@@ -512,67 +482,93 @@ function extractBlockedWritePath(output: string, cwd: string): string | null {
|
|
|
512
482
|
return extractNativeWriteDeniedPath(output, cwd);
|
|
513
483
|
}
|
|
514
484
|
|
|
515
|
-
|
|
516
|
-
|
|
485
|
+
// Returns the first `length` elements of a string tuple, or null if `value` is
|
|
486
|
+
// not an array of at least that many strings.
|
|
487
|
+
function stringTuple(value: unknown, length: number): string[] | null {
|
|
488
|
+
if (!Array.isArray(value) || value.length < length) return null;
|
|
489
|
+
const head = value.slice(0, length);
|
|
490
|
+
return head.every((item) => typeof item === 'string') ? (head as string[]) : null;
|
|
491
|
+
}
|
|
517
492
|
|
|
518
|
-
|
|
519
|
-
|
|
493
|
+
// landstrip emits each trap as a serde externally-tagged enum: a single-key
|
|
494
|
+
// object whose key is the variant name (`Filesystem`, `Network`, `Launch`,
|
|
495
|
+
// `Usage`, `Internal`) and whose value is the variant payload.
|
|
496
|
+
function parseLandstripTrap(obj: Record<string, unknown>): LandstripTrap | null {
|
|
497
|
+
const fs = stringTuple(obj.Filesystem, 3);
|
|
498
|
+
if (fs) {
|
|
499
|
+
const [operation, file, mechanism] = fs;
|
|
500
|
+
if (operation === 'read' || operation === 'write') {
|
|
501
|
+
return { kind: 'filesystem', operation, file, mechanism };
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
520
505
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
506
|
+
const net = stringTuple(obj.Network, 3);
|
|
507
|
+
if (net) {
|
|
508
|
+
const [operation, target, mechanism] = net;
|
|
509
|
+
return { kind: 'network', operation, target, mechanism };
|
|
510
|
+
}
|
|
525
511
|
|
|
526
|
-
|
|
527
|
-
|
|
512
|
+
const launch = stringTuple(obj.Launch, 2);
|
|
513
|
+
if (launch) {
|
|
514
|
+
const [program, source] = launch;
|
|
515
|
+
return { kind: 'launch', program, source };
|
|
516
|
+
}
|
|
528
517
|
|
|
529
|
-
|
|
518
|
+
if (typeof obj.Usage === 'string') {
|
|
519
|
+
return { kind: 'usage', message: obj.Usage };
|
|
520
|
+
}
|
|
530
521
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
522
|
+
const internal = obj.Internal;
|
|
523
|
+
if (typeof internal === 'object' && internal !== null && !Array.isArray(internal)) {
|
|
524
|
+
const detail: Record<string, string> = {};
|
|
525
|
+
for (const [key, value] of Object.entries(internal)) {
|
|
526
|
+
if (typeof value === 'string') detail[key] = value;
|
|
527
|
+
}
|
|
528
|
+
return { kind: 'internal', detail };
|
|
529
|
+
}
|
|
534
530
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
538
533
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
534
|
+
function parseLandstripTraps(output: string): LandstripTrap[] {
|
|
535
|
+
const traps: LandstripTrap[] = [];
|
|
542
536
|
|
|
543
|
-
|
|
537
|
+
for (const line of output.trim().split('\n')) {
|
|
538
|
+
if (line.length === 0) continue;
|
|
544
539
|
|
|
545
|
-
|
|
540
|
+
try {
|
|
541
|
+
const parsed: unknown = JSON.parse(line);
|
|
542
|
+
if (typeof parsed !== 'object' || parsed === null) continue;
|
|
543
|
+
const trap = parseLandstripTrap(parsed as Record<string, unknown>);
|
|
544
|
+
if (trap) traps.push(trap);
|
|
546
545
|
} catch {
|
|
547
546
|
// Ignore non-JSON lines (e.g. stderr from child processes)
|
|
548
547
|
}
|
|
549
548
|
}
|
|
550
549
|
|
|
551
|
-
return
|
|
550
|
+
return traps;
|
|
552
551
|
}
|
|
553
552
|
|
|
554
|
-
function
|
|
555
|
-
return
|
|
556
|
-
.map((
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
parts.push(`: ${err.source}`);
|
|
553
|
+
function formatLandstripTraps(traps: LandstripTrap[]): string {
|
|
554
|
+
return traps
|
|
555
|
+
.map((trap) => {
|
|
556
|
+
switch (trap.kind) {
|
|
557
|
+
case 'filesystem':
|
|
558
|
+
return `landstrip: filesystem ${trap.operation} denied: ${trap.file} (${trap.mechanism})`;
|
|
559
|
+
case 'network':
|
|
560
|
+
return `landstrip: network ${trap.operation} denied: ${trap.target} (${trap.mechanism})`;
|
|
561
|
+
case 'launch':
|
|
562
|
+
return `landstrip: launch failed: ${trap.program}: ${trap.source}`;
|
|
563
|
+
case 'usage':
|
|
564
|
+
return `landstrip: usage error: ${trap.message}`;
|
|
565
|
+
case 'internal': {
|
|
566
|
+
const detail = Object.entries(trap.detail)
|
|
567
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
568
|
+
.join(', ');
|
|
569
|
+
return detail ? `landstrip: internal error: ${detail}` : 'landstrip: internal error';
|
|
570
|
+
}
|
|
573
571
|
}
|
|
574
|
-
|
|
575
|
-
return parts.join('');
|
|
576
572
|
})
|
|
577
573
|
.join('\n');
|
|
578
574
|
}
|
|
@@ -1249,9 +1245,9 @@ export function createLandstripIntegration(
|
|
|
1249
1245
|
if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
|
|
1250
1246
|
}
|
|
1251
1247
|
} else if (!blockedPath && ctx.hasUI) {
|
|
1252
|
-
const landstripErrors =
|
|
1248
|
+
const landstripErrors = parseLandstripTraps(errorOutput);
|
|
1253
1249
|
if (landstripErrors.length > 0) {
|
|
1254
|
-
const formatted =
|
|
1250
|
+
const formatted = formatLandstripTraps(landstripErrors);
|
|
1255
1251
|
notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
|
|
1256
1252
|
}
|
|
1257
1253
|
}
|
|
@@ -1342,15 +1338,15 @@ export function createLandstripIntegration(
|
|
|
1342
1338
|
if (retryResult) return retryResult;
|
|
1343
1339
|
}
|
|
1344
1340
|
|
|
1345
|
-
const landstripErrors =
|
|
1341
|
+
const landstripErrors = parseLandstripTraps(landstripErrorOutput || errorText);
|
|
1346
1342
|
if (landstripErrors.length > 0) {
|
|
1347
|
-
throw new Error(
|
|
1343
|
+
throw new Error(formatLandstripTraps(landstripErrors));
|
|
1348
1344
|
}
|
|
1349
1345
|
throw error;
|
|
1350
1346
|
}
|
|
1351
|
-
const landstripErrors =
|
|
1347
|
+
const landstripErrors = parseLandstripTraps(landstripErrorOutput);
|
|
1352
1348
|
if (landstripErrors.length > 0) {
|
|
1353
|
-
const message =
|
|
1349
|
+
const message = formatLandstripTraps(landstripErrors);
|
|
1354
1350
|
result.content.unshift({ type: 'text', text: `\n${message}\n` });
|
|
1355
1351
|
}
|
|
1356
1352
|
const blockedPath =
|
|
@@ -1428,7 +1424,7 @@ export function createLandstripIntegration(
|
|
|
1428
1424
|
sandboxReady = false;
|
|
1429
1425
|
notify(
|
|
1430
1426
|
ctx,
|
|
1431
|
-
`landstrip was not found. Reinstall with: npm install @
|
|
1427
|
+
`landstrip was not found. Reinstall with: npm install @landstrip/landstrip`,
|
|
1432
1428
|
'error',
|
|
1433
1429
|
);
|
|
1434
1430
|
return false;
|
package/landstrip.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-landstrip",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Landlock-based sandboxing for pi with interactive permission prompts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landstrip",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/
|
|
14
|
+
"url": "git+https://github.com/landstrip/pi-landstrip.git"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"index.ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@earendil-works/pi-tui": "^0.78.0",
|
|
34
|
-
"@
|
|
34
|
+
"@landstrip/landstrip": "^0.14.5"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@earendil-works/pi-coding-agent": "^0.78.0",
|