pi-landstrip 0.11.1 → 0.11.2

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 +94 -45
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -72,15 +72,36 @@ interface LandstripPolicy {
72
72
  filesystem: SandboxFilesystemConfig;
73
73
  }
74
74
 
75
+ type LandstripErrorReason = 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
76
+ type LandstripOperation = 'read' | 'write';
77
+ type LandstripErrorType = 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
78
+
75
79
  interface LandstripErrorResponse {
76
- reason: 'Other' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
80
+ reason: LandstripErrorReason;
77
81
  file?: string;
82
+ operation?: LandstripOperation;
78
83
  program?: string;
79
- type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
80
- source: string;
84
+ type?: LandstripErrorType;
85
+ source?: string;
81
86
  }
82
87
 
83
- const LANDSTRIP_VERSION = [0, 11, 5] as const;
88
+ const LANDSTRIP_VERSION = [0, 11, 6] as const;
89
+ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
90
+ const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorReason>([
91
+ 'Other',
92
+ 'AccessDenied',
93
+ 'LaunchFailed',
94
+ 'SetupFailed',
95
+ 'Usage',
96
+ ]);
97
+ const LANDSTRIP_OPERATIONS = new Set<LandstripOperation>(['read', 'write']);
98
+ const LANDSTRIP_ERROR_TYPES = new Set<LandstripErrorType>([
99
+ 'filesystem',
100
+ 'network',
101
+ 'platform',
102
+ 'launch',
103
+ 'encoding',
104
+ ]);
84
105
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
85
106
 
86
107
  const DEFAULT_CONFIG: SandboxConfig = {
@@ -348,6 +369,22 @@ function normalizePathMatch(value: string, cwd: string): string | null {
348
369
  return isPathLike(value) ? normalizeBlockedPath(value, cwd) : null;
349
370
  }
350
371
 
372
+ function isFilesystemAccessDenied(error: LandstripErrorResponse): boolean {
373
+ return error.reason === 'AccessDenied' && error.type === 'filesystem';
374
+ }
375
+
376
+ function isLandstripErrorReason(value: string): value is LandstripErrorReason {
377
+ return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorReason);
378
+ }
379
+
380
+ function isLandstripOperation(value: string): value is LandstripOperation {
381
+ return LANDSTRIP_OPERATIONS.has(value as LandstripOperation);
382
+ }
383
+
384
+ function isLandstripErrorType(value: string): value is LandstripErrorType {
385
+ return LANDSTRIP_ERROR_TYPES.has(value as LandstripErrorType);
386
+ }
387
+
351
388
  function extractCandidatePaths(command: string): string[] {
352
389
  const paths: string[] = [];
353
390
  // Split on whitespace, preserving quoted strings minimally
@@ -362,6 +399,26 @@ function extractCandidatePaths(command: string): string[] {
362
399
  }
363
400
 
364
401
  function extractBlockedPath(output: string, cwd: string, command?: string): string | null {
402
+ const landstripErrors = parseLandstripErrors(output).filter(isFilesystemAccessDenied);
403
+ for (const error of landstripErrors) {
404
+ if (error.file) return normalizeBlockedPath(error.file, cwd);
405
+ }
406
+
407
+ // If landstrip reported an error but without a file field, try to
408
+ // extract the blocked path from the command itself.
409
+ if (landstripErrors.length > 0 && command) {
410
+ const config = loadConfig(cwd);
411
+ for (const candidate of extractCandidatePaths(command)) {
412
+ const resolved = normalizeBlockedPath(candidate, cwd);
413
+ if (
414
+ matchesPattern(resolved, config.filesystem.denyRead) ||
415
+ !matchesPattern(resolved, config.filesystem.allowRead)
416
+ ) {
417
+ return resolved;
418
+ }
419
+ }
420
+ }
421
+
365
422
  // bash/sh: line X: /path: Permission denied
366
423
  let match = output.match(
367
424
  /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
@@ -376,38 +433,23 @@ function extractBlockedPath(output: string, cwd: string, command?: string): stri
376
433
 
377
434
  // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
378
435
  match = output.match(
379
- /^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
436
+ /^[a-zA-Z0-9_-]+: (\/[^:\n]+): (?:Operation not permitted|Permission denied)$/m,
380
437
  );
381
438
  if (match) return normalizeBlockedPath(match[1], cwd);
382
439
 
383
- // Landstrip structured error format with file field
384
- const landstripErrors = parseLandstripErrors(output);
385
- for (const error of landstripErrors) {
386
- if (error.file) return normalizeBlockedPath(error.file, cwd);
387
- }
440
+ return null;
441
+ }
388
442
 
389
- // If landstrip reported an error but without a file field, try to
390
- // extract the blocked path from the command itself
391
- if (landstripErrors.length > 0 && command) {
392
- const config = loadConfig(cwd);
393
- for (const candidate of extractCandidatePaths(command)) {
394
- const resolved = normalizeBlockedPath(candidate, cwd);
395
- if (
396
- matchesPattern(resolved, config.filesystem.denyRead) ||
397
- !matchesPattern(resolved, config.filesystem.allowRead)
398
- ) {
399
- return resolved;
400
- }
443
+ function extractBlockedWritePath(output: string, cwd: string): string | null {
444
+ for (const error of parseLandstripErrors(output).filter(isFilesystemAccessDenied)) {
445
+ if (error.file && error.operation === 'write') {
446
+ return normalizeBlockedPath(error.file, cwd);
401
447
  }
402
448
  }
403
449
 
404
450
  return null;
405
451
  }
406
452
 
407
- function extractBlockedWritePath(output: string, cwd: string, command?: string): string | null {
408
- return extractBlockedPath(output, cwd, command);
409
- }
410
-
411
453
  function parseLandstripErrors(output: string): LandstripErrorResponse[] {
412
454
  const errors: LandstripErrorResponse[] = [];
413
455
 
@@ -424,22 +466,21 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
424
466
 
425
467
  if (
426
468
  fields.reason &&
427
- ['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
428
- fields.source
469
+ isLandstripErrorReason(fields.reason) &&
470
+ (fields.source || fields.reason === 'AccessDenied')
429
471
  ) {
430
- const error: LandstripErrorResponse = {
431
- reason: fields.reason as LandstripErrorResponse['reason'],
432
- source: fields.source,
433
- };
472
+ const error: LandstripErrorResponse = { reason: fields.reason };
434
473
 
474
+ if (fields.source) error.source = fields.source;
435
475
  if (fields.file) error.file = fields.file;
436
476
  if (fields.program) error.program = fields.program;
437
477
 
438
- if (
439
- fields.type &&
440
- ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
441
- ) {
442
- error.type = fields.type as LandstripErrorResponse['type'];
478
+ if (fields.operation && isLandstripOperation(fields.operation)) {
479
+ error.operation = fields.operation;
480
+ }
481
+
482
+ if (fields.type && isLandstripErrorType(fields.type)) {
483
+ error.type = fields.type;
443
484
  }
444
485
 
445
486
  errors.push(error);
@@ -463,7 +504,12 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
463
504
  if (err.type) {
464
505
  parts.push(`:${err.type}`);
465
506
  }
466
- parts.push(`: ${err.source}`);
507
+ if (err.operation) {
508
+ parts.push(`:${err.operation}`);
509
+ }
510
+ if (err.source) {
511
+ parts.push(`: ${err.source}`);
512
+ }
467
513
 
468
514
  return parts.join('');
469
515
  })
@@ -1085,6 +1131,7 @@ export function createLandstripIntegration(
1085
1131
  }
1086
1132
 
1087
1133
  const blockedPath = extractBlockedPath(stderrAcc, cwd, command);
1134
+ const blockedWritePath = extractBlockedWritePath(stderrAcc, cwd);
1088
1135
  if (blockedPath && ctx.hasUI) {
1089
1136
  const config = loadConfig(cwd);
1090
1137
  const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
@@ -1095,7 +1142,10 @@ export function createLandstripIntegration(
1095
1142
  matchesPattern,
1096
1143
  );
1097
1144
 
1098
- if (isDeniedByDenyRead || !isReadAllowed) {
1145
+ if (blockedWritePath === blockedPath && !isWriteAllowed) {
1146
+ const choice = await promptWriteBlock(ctx, blockedPath);
1147
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1148
+ } else if (isDeniedByDenyRead || !isReadAllowed) {
1099
1149
  const choice = await promptReadBlock(
1100
1150
  ctx,
1101
1151
  blockedPath,
@@ -1147,16 +1197,12 @@ export function createLandstripIntegration(
1147
1197
  }
1148
1198
  throw error;
1149
1199
  }
1150
- const outputText = result.content
1151
- .filter((content) => content.type === 'text')
1152
- .map((content) => content.text)
1153
- .join('\n');
1154
1200
  const landstripErrors = parseLandstripErrors(landstripStderr);
1155
1201
  if (landstripErrors.length > 0) {
1156
1202
  const message = formatLandstripErrors(landstripErrors);
1157
1203
  result.content.unshift({ type: 'text', text: `\n${message}\n` });
1158
1204
  }
1159
- const blockedPath = extractBlockedWritePath(outputText, ctx.cwd, params.command);
1205
+ const blockedPath = extractBlockedWritePath(landstripStderr, ctx.cwd);
1160
1206
 
1161
1207
  if (!blockedPath || !ctx.hasUI) return result;
1162
1208
 
@@ -1256,7 +1302,10 @@ export function createLandstripIntegration(
1256
1302
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
1257
1303
  sandboxEnabled = false;
1258
1304
  sandboxReady = false;
1259
- ctx.ui.notify(`landstrip 0.11.5 or newer is required; found: ${version}`, 'error');
1305
+ ctx.ui.notify(
1306
+ `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
1307
+ 'error',
1308
+ );
1260
1309
  return false;
1261
1310
  }
1262
1311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@earendil-works/pi-tui": "^0.78.0",
34
- "@jarkkojs/landstrip": "^0.11.5"
34
+ "@jarkkojs/landstrip": "^0.11.6"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",