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 CHANGED
@@ -3,7 +3,7 @@
3
3
  ![pi-landstrip screenshot](screenshot.png)
4
4
 
5
5
  Landlock-based sandboxing for [pi](https://pi.dev/) using
6
- [`landstrip`](https://github.com/jarkkojs/landstrip).
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 `@jarkkojs/landstrip` dependency, which
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 `@jarkkojs/landstrip` package is licensed under
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 '@jarkkojs/landstrip';
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
- type LandstripErrorType = 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
82
-
83
- interface LandstripErrorResponse {
84
- reason: LandstripErrorReason;
85
- file?: string;
86
- operation?: LandstripOperation;
87
- program?: string;
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, 12, 2] as const;
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(homedir(), '.pi', 'agent', 'sandbox.json'),
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
- function isFilesystemAccessDenied(error: LandstripErrorResponse): boolean {
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 extractCandidatePaths(command: string): string[] {
425
- const paths: string[] = [];
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, command?: string): string | null {
438
- const landstripErrors = parseLandstripErrors(output).filter(isFilesystemAccessDenied);
439
- for (const error of landstripErrors) {
440
- if (error.file) return normalizeBlockedPath(error.file, cwd);
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 parseLandstripErrors(output).filter(isFilesystemAccessDenied)) {
507
- if (error.file && error.operation === 'write') {
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
- function parseLandstripErrors(output: string): LandstripErrorResponse[] {
516
- const errors: LandstripErrorResponse[] = [];
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
- for (const line of output.trim().split('\n')) {
519
- if (line.length === 0) continue;
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
- try {
522
- const parsed: unknown = JSON.parse(line);
523
- if (typeof parsed !== 'object' || parsed === null) continue;
524
- const obj = parsed as Record<string, unknown>;
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
- const reason = obj.reason;
527
- if (typeof reason !== 'string' || !isLandstripErrorReason(reason)) continue;
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
- const error: LandstripErrorResponse = { reason };
490
+ if (typeof obj.Usage === 'string') {
491
+ return { kind: 'usage', message: obj.Usage };
492
+ }
530
493
 
531
- if (typeof obj.source === 'string') error.source = obj.source;
532
- if (typeof obj.file === 'string') error.file = obj.file;
533
- if (typeof obj.program === 'string') error.program = obj.program;
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
- if (typeof obj.operation === 'string' && isLandstripOperation(obj.operation)) {
536
- error.operation = obj.operation;
537
- }
503
+ return null;
504
+ }
538
505
 
539
- if (typeof obj.type === 'string' && isLandstripErrorType(obj.type)) {
540
- error.type = obj.type;
541
- }
506
+ function parseLandstripTraps(output: string): LandstripTrap[] {
507
+ const traps: LandstripTrap[] = [];
542
508
 
543
- if (typeof obj.mechanism === 'string') error.mechanism = obj.mechanism;
509
+ for (const line of output.trim().split('\n')) {
510
+ if (line.length === 0) continue;
544
511
 
545
- errors.push(error);
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 errors;
522
+ return traps;
552
523
  }
553
524
 
554
- function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
555
- return errors
556
- .map((err) => {
557
- const parts: string[] = [`landstrip: ${err.reason}`];
558
-
559
- if (err.file) {
560
- parts.push(` (${err.file})`);
561
- }
562
- if (err.program) {
563
- parts.push(` ${err.program}`);
564
- }
565
- if (err.type) {
566
- parts.push(`:${err.type}`);
567
- }
568
- if (err.operation) {
569
- parts.push(`:${err.operation}`);
570
- }
571
- if (err.source) {
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: [...config.filesystem.allowRead, ...sessionAllowedReadPaths],
990
- allowWrite: [...config.filesystem.allowWrite, ...sessionAllowedWritePaths],
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, command) ??
1223
- (errorFdAcc ? extractBlockedPath(stderrAcc, cwd, command) : null);
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 = parseLandstripErrors(errorOutput);
1234
+ const landstripErrors = parseLandstripTraps(errorOutput);
1253
1235
  if (landstripErrors.length > 0) {
1254
- const formatted = formatLandstripErrors(landstripErrors);
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 = parseLandstripErrors(landstripErrorOutput || errorText);
1327
+ const landstripErrors = parseLandstripTraps(landstripErrorOutput || errorText);
1346
1328
  if (landstripErrors.length > 0) {
1347
- throw new Error(formatLandstripErrors(landstripErrors));
1329
+ throw new Error(formatLandstripTraps(landstripErrors));
1348
1330
  }
1349
1331
  throw error;
1350
1332
  }
1351
- const landstripErrors = parseLandstripErrors(landstripErrorOutput);
1333
+ const landstripErrors = parseLandstripTraps(landstripErrorOutput);
1352
1334
  if (landstripErrors.length > 0) {
1353
- const message = formatLandstripErrors(landstripErrors);
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 @jarkkojs/landstrip`,
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
@@ -1,3 +1,3 @@
1
- declare module '@jarkkojs/landstrip' {
1
+ declare module '@landstrip/landstrip' {
2
2
  function binaryPath(): string;
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.12.1",
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/jarkkojs/pi-landstrip.git"
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
- "@jarkkojs/landstrip": "^0.12.3"
34
+ "@landstrip/landstrip": "^0.14.5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",