pi-landstrip 0.12.1 → 0.14.1
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 +114 -132
- 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, 5] 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 = {
|
|
@@ -218,7 +198,7 @@ function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): Sand
|
|
|
218
198
|
|
|
219
199
|
function getConfigPaths(cwd: string): { globalPath: string; projectPath: string } {
|
|
220
200
|
return {
|
|
221
|
-
globalPath: join(
|
|
201
|
+
globalPath: join(getAgentDir(), 'sandbox.json'),
|
|
222
202
|
projectPath: join(cwd, '.pi', 'sandbox.json'),
|
|
223
203
|
};
|
|
224
204
|
}
|
|
@@ -405,54 +385,16 @@ 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
|
-
}
|
|
419
|
-
|
|
420
|
-
function isLandstripErrorType(value: string): value is LandstripErrorType {
|
|
421
|
-
return LANDSTRIP_ERROR_TYPES.has(value as LandstripErrorType);
|
|
422
|
-
}
|
|
388
|
+
type LandstripFilesystemTrap = Extract<LandstripTrap, { kind: 'filesystem' }>;
|
|
423
389
|
|
|
424
|
-
function
|
|
425
|
-
|
|
426
|
-
// Split on whitespace, preserving quoted strings minimally
|
|
427
|
-
const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/g) ?? [];
|
|
428
|
-
for (const token of tokens) {
|
|
429
|
-
const clean = token.replace(/^["']|["']$/g, '').replace(/[,;]$/, '');
|
|
430
|
-
if (isPathLike(clean)) {
|
|
431
|
-
paths.push(clean);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
return paths;
|
|
390
|
+
function isFilesystemTrap(trap: LandstripTrap): trap is LandstripFilesystemTrap {
|
|
391
|
+
return trap.kind === 'filesystem';
|
|
435
392
|
}
|
|
436
393
|
|
|
437
|
-
function extractBlockedPath(output: string, cwd: string
|
|
438
|
-
const landstripErrors =
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// If landstrip reported an error but without a file field, try to
|
|
444
|
-
// extract the blocked path from the command itself.
|
|
445
|
-
if (landstripErrors.length > 0 && command) {
|
|
446
|
-
const config = loadConfig(cwd);
|
|
447
|
-
for (const candidate of extractCandidatePaths(command)) {
|
|
448
|
-
const resolved = normalizeBlockedPath(candidate, cwd);
|
|
449
|
-
if (
|
|
450
|
-
matchesPattern(resolved, config.filesystem.denyRead) ||
|
|
451
|
-
!matchesPattern(resolved, config.filesystem.allowRead)
|
|
452
|
-
) {
|
|
453
|
-
return resolved;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
394
|
+
function extractBlockedPath(output: string, cwd: string): string | null {
|
|
395
|
+
const landstripErrors = parseLandstripTraps(output).filter(isFilesystemTrap);
|
|
396
|
+
if (landstripErrors.length > 0) {
|
|
397
|
+
return normalizeBlockedPath(landstripErrors[0].file, cwd);
|
|
456
398
|
}
|
|
457
399
|
|
|
458
400
|
return extractNativeDeniedPath(output, cwd);
|
|
@@ -503,8 +445,8 @@ function extractNativeWriteDeniedPath(output: string, cwd: string): string | nul
|
|
|
503
445
|
}
|
|
504
446
|
|
|
505
447
|
function extractBlockedWritePath(output: string, cwd: string): string | null {
|
|
506
|
-
for (const error of
|
|
507
|
-
if (error.
|
|
448
|
+
for (const error of parseLandstripTraps(output).filter(isFilesystemTrap)) {
|
|
449
|
+
if (error.operation === 'write') {
|
|
508
450
|
return normalizeBlockedPath(error.file, cwd);
|
|
509
451
|
}
|
|
510
452
|
}
|
|
@@ -512,67 +454,93 @@ function extractBlockedWritePath(output: string, cwd: string): string | null {
|
|
|
512
454
|
return extractNativeWriteDeniedPath(output, cwd);
|
|
513
455
|
}
|
|
514
456
|
|
|
515
|
-
|
|
516
|
-
|
|
457
|
+
// Returns the first `length` elements of a string tuple, or null if `value` is
|
|
458
|
+
// not an array of at least that many strings.
|
|
459
|
+
function stringTuple(value: unknown, length: number): string[] | null {
|
|
460
|
+
if (!Array.isArray(value) || value.length < length) return null;
|
|
461
|
+
const head = value.slice(0, length);
|
|
462
|
+
return head.every((item) => typeof item === 'string') ? (head as string[]) : null;
|
|
463
|
+
}
|
|
517
464
|
|
|
518
|
-
|
|
519
|
-
|
|
465
|
+
// landstrip emits each trap as a serde externally-tagged enum: a single-key
|
|
466
|
+
// object whose key is the variant name (`Filesystem`, `Network`, `Launch`,
|
|
467
|
+
// `Usage`, `Internal`) and whose value is the variant payload.
|
|
468
|
+
function parseLandstripTrap(obj: Record<string, unknown>): LandstripTrap | null {
|
|
469
|
+
const fs = stringTuple(obj.Filesystem, 3);
|
|
470
|
+
if (fs) {
|
|
471
|
+
const [operation, file, mechanism] = fs;
|
|
472
|
+
if (operation === 'read' || operation === 'write') {
|
|
473
|
+
return { kind: 'filesystem', operation, file, mechanism };
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
520
477
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
478
|
+
const net = stringTuple(obj.Network, 3);
|
|
479
|
+
if (net) {
|
|
480
|
+
const [operation, target, mechanism] = net;
|
|
481
|
+
return { kind: 'network', operation, target, mechanism };
|
|
482
|
+
}
|
|
525
483
|
|
|
526
|
-
|
|
527
|
-
|
|
484
|
+
const launch = stringTuple(obj.Launch, 2);
|
|
485
|
+
if (launch) {
|
|
486
|
+
const [program, source] = launch;
|
|
487
|
+
return { kind: 'launch', program, source };
|
|
488
|
+
}
|
|
528
489
|
|
|
529
|
-
|
|
490
|
+
if (typeof obj.Usage === 'string') {
|
|
491
|
+
return { kind: 'usage', message: obj.Usage };
|
|
492
|
+
}
|
|
530
493
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
494
|
+
const internal = obj.Internal;
|
|
495
|
+
if (typeof internal === 'object' && internal !== null && !Array.isArray(internal)) {
|
|
496
|
+
const detail: Record<string, string> = {};
|
|
497
|
+
for (const [key, value] of Object.entries(internal)) {
|
|
498
|
+
if (typeof value === 'string') detail[key] = value;
|
|
499
|
+
}
|
|
500
|
+
return { kind: 'internal', detail };
|
|
501
|
+
}
|
|
534
502
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
538
505
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
506
|
+
function parseLandstripTraps(output: string): LandstripTrap[] {
|
|
507
|
+
const traps: LandstripTrap[] = [];
|
|
542
508
|
|
|
543
|
-
|
|
509
|
+
for (const line of output.trim().split('\n')) {
|
|
510
|
+
if (line.length === 0) continue;
|
|
544
511
|
|
|
545
|
-
|
|
512
|
+
try {
|
|
513
|
+
const parsed: unknown = JSON.parse(line);
|
|
514
|
+
if (typeof parsed !== 'object' || parsed === null) continue;
|
|
515
|
+
const trap = parseLandstripTrap(parsed as Record<string, unknown>);
|
|
516
|
+
if (trap) traps.push(trap);
|
|
546
517
|
} catch {
|
|
547
518
|
// Ignore non-JSON lines (e.g. stderr from child processes)
|
|
548
519
|
}
|
|
549
520
|
}
|
|
550
521
|
|
|
551
|
-
return
|
|
522
|
+
return traps;
|
|
552
523
|
}
|
|
553
524
|
|
|
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}`);
|
|
525
|
+
function formatLandstripTraps(traps: LandstripTrap[]): string {
|
|
526
|
+
return traps
|
|
527
|
+
.map((trap) => {
|
|
528
|
+
switch (trap.kind) {
|
|
529
|
+
case 'filesystem':
|
|
530
|
+
return `landstrip: filesystem ${trap.operation} denied: ${trap.file} (${trap.mechanism})`;
|
|
531
|
+
case 'network':
|
|
532
|
+
return `landstrip: network ${trap.operation} denied: ${trap.target} (${trap.mechanism})`;
|
|
533
|
+
case 'launch':
|
|
534
|
+
return `landstrip: launch failed: ${trap.program}: ${trap.source}`;
|
|
535
|
+
case 'usage':
|
|
536
|
+
return `landstrip: usage error: ${trap.message}`;
|
|
537
|
+
case 'internal': {
|
|
538
|
+
const detail = Object.entries(trap.detail)
|
|
539
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
540
|
+
.join(', ');
|
|
541
|
+
return detail ? `landstrip: internal error: ${detail}` : 'landstrip: internal error';
|
|
542
|
+
}
|
|
573
543
|
}
|
|
574
|
-
|
|
575
|
-
return parts.join('');
|
|
576
544
|
})
|
|
577
545
|
.join('\n');
|
|
578
546
|
}
|
|
@@ -986,8 +954,8 @@ export function createLandstripIntegration(
|
|
|
986
954
|
},
|
|
987
955
|
filesystem: {
|
|
988
956
|
denyRead: config.filesystem.denyRead,
|
|
989
|
-
allowRead:
|
|
990
|
-
allowWrite:
|
|
957
|
+
allowRead: getEffectiveAllowRead(cwd),
|
|
958
|
+
allowWrite: getEffectiveAllowWrite(cwd),
|
|
991
959
|
denyWrite: config.filesystem.denyWrite,
|
|
992
960
|
},
|
|
993
961
|
};
|
|
@@ -1023,10 +991,17 @@ export function createLandstripIntegration(
|
|
|
1023
991
|
return;
|
|
1024
992
|
}
|
|
1025
993
|
|
|
994
|
+
let settled = false;
|
|
1026
995
|
const upstream = connectNet(endpoint.port, endpoint.host, () => {
|
|
996
|
+
settled = true;
|
|
1027
997
|
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
1028
998
|
pipeSockets(client, upstream, rest);
|
|
1029
999
|
});
|
|
1000
|
+
upstream.once('error', () => {
|
|
1001
|
+
if (settled) return;
|
|
1002
|
+
settled = true;
|
|
1003
|
+
denyProxyRequest(client, '502 Bad Gateway');
|
|
1004
|
+
});
|
|
1030
1005
|
}
|
|
1031
1006
|
|
|
1032
1007
|
async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
|
|
@@ -1065,10 +1040,17 @@ export function createLandstripIntegration(
|
|
|
1065
1040
|
const rewrittenHeader = lines
|
|
1066
1041
|
.filter((line) => !line.toLowerCase().startsWith('proxy-connection:'))
|
|
1067
1042
|
.join('\r\n');
|
|
1043
|
+
let settled = false;
|
|
1068
1044
|
const upstream = connectNet(port, url.hostname, () => {
|
|
1045
|
+
settled = true;
|
|
1069
1046
|
upstream.write(`${rewrittenHeader}\r\n\r\n`);
|
|
1070
1047
|
pipeSockets(client, upstream, rest);
|
|
1071
1048
|
});
|
|
1049
|
+
upstream.once('error', () => {
|
|
1050
|
+
if (settled) return;
|
|
1051
|
+
settled = true;
|
|
1052
|
+
denyProxyRequest(client, '502 Bad Gateway');
|
|
1053
|
+
});
|
|
1072
1054
|
}
|
|
1073
1055
|
|
|
1074
1056
|
function handleClient(client: Socket): void {
|
|
@@ -1219,8 +1201,8 @@ export function createLandstripIntegration(
|
|
|
1219
1201
|
const errorOutput = errorFdAcc || stderrAcc;
|
|
1220
1202
|
|
|
1221
1203
|
const blockedPath =
|
|
1222
|
-
extractBlockedPath(errorOutput, cwd
|
|
1223
|
-
(errorFdAcc ? extractBlockedPath(stderrAcc, cwd
|
|
1204
|
+
extractBlockedPath(errorOutput, cwd) ??
|
|
1205
|
+
(errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
|
|
1224
1206
|
const blockedWritePath =
|
|
1225
1207
|
extractBlockedWritePath(errorOutput, cwd) ??
|
|
1226
1208
|
(errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
|
|
@@ -1249,9 +1231,9 @@ export function createLandstripIntegration(
|
|
|
1249
1231
|
if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
|
|
1250
1232
|
}
|
|
1251
1233
|
} else if (!blockedPath && ctx.hasUI) {
|
|
1252
|
-
const landstripErrors =
|
|
1234
|
+
const landstripErrors = parseLandstripTraps(errorOutput);
|
|
1253
1235
|
if (landstripErrors.length > 0) {
|
|
1254
|
-
const formatted =
|
|
1236
|
+
const formatted = formatLandstripTraps(landstripErrors);
|
|
1255
1237
|
notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
|
|
1256
1238
|
}
|
|
1257
1239
|
}
|
|
@@ -1342,15 +1324,15 @@ export function createLandstripIntegration(
|
|
|
1342
1324
|
if (retryResult) return retryResult;
|
|
1343
1325
|
}
|
|
1344
1326
|
|
|
1345
|
-
const landstripErrors =
|
|
1327
|
+
const landstripErrors = parseLandstripTraps(landstripErrorOutput || errorText);
|
|
1346
1328
|
if (landstripErrors.length > 0) {
|
|
1347
|
-
throw new Error(
|
|
1329
|
+
throw new Error(formatLandstripTraps(landstripErrors));
|
|
1348
1330
|
}
|
|
1349
1331
|
throw error;
|
|
1350
1332
|
}
|
|
1351
|
-
const landstripErrors =
|
|
1333
|
+
const landstripErrors = parseLandstripTraps(landstripErrorOutput);
|
|
1352
1334
|
if (landstripErrors.length > 0) {
|
|
1353
|
-
const message =
|
|
1335
|
+
const message = formatLandstripTraps(landstripErrors);
|
|
1354
1336
|
result.content.unshift({ type: 'text', text: `\n${message}\n` });
|
|
1355
1337
|
}
|
|
1356
1338
|
const blockedPath =
|
|
@@ -1428,7 +1410,7 @@ export function createLandstripIntegration(
|
|
|
1428
1410
|
sandboxReady = false;
|
|
1429
1411
|
notify(
|
|
1430
1412
|
ctx,
|
|
1431
|
-
`landstrip was not found. Reinstall with: npm install @
|
|
1413
|
+
`landstrip was not found. Reinstall with: npm install @landstrip/landstrip`,
|
|
1432
1414
|
'error',
|
|
1433
1415
|
);
|
|
1434
1416
|
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.1",
|
|
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",
|