pi-landstrip 0.11.0 → 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 +114 -60
  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, 3] 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 = {
@@ -226,9 +247,6 @@ function addReadPathToConfig(configPath: string, pathToAdd: string): void {
226
247
  config.filesystem = {
227
248
  ...config.filesystem,
228
249
  allowRead: [...existing, pathToAdd],
229
- denyRead: config.filesystem?.denyRead ?? [],
230
- allowWrite: config.filesystem?.allowWrite ?? [],
231
- denyWrite: config.filesystem?.denyWrite ?? [],
232
250
  } as SandboxFilesystemConfig;
233
251
  writeConfigFile(configPath, config);
234
252
  }
@@ -241,9 +259,6 @@ function addWritePathToConfig(configPath: string, pathToAdd: string): void {
241
259
  config.filesystem = {
242
260
  ...config.filesystem,
243
261
  allowWrite: [...existing, pathToAdd],
244
- denyRead: config.filesystem?.denyRead ?? [],
245
- allowRead: config.filesystem?.allowRead ?? [],
246
- denyWrite: config.filesystem?.denyWrite ?? [],
247
262
  } as SandboxFilesystemConfig;
248
263
  writeConfigFile(configPath, config);
249
264
  }
@@ -337,19 +352,46 @@ function normalizeBlockedPath(path: string, cwd: string): string {
337
352
  return canonicalizePath(isAbsolute(path) ? path : join(cwd, path));
338
353
  }
339
354
 
355
+ function isPathLike(value: string): boolean {
356
+ const trimmed = value.trim();
357
+ return (
358
+ trimmed === '~' ||
359
+ trimmed.startsWith('/') ||
360
+ trimmed.startsWith('~/') ||
361
+ trimmed.startsWith('./') ||
362
+ trimmed.startsWith('../') ||
363
+ trimmed.startsWith('.') ||
364
+ trimmed.includes('/')
365
+ );
366
+ }
367
+
368
+ function normalizePathMatch(value: string, cwd: string): string | null {
369
+ return isPathLike(value) ? normalizeBlockedPath(value, cwd) : null;
370
+ }
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
+
340
388
  function extractCandidatePaths(command: string): string[] {
341
389
  const paths: string[] = [];
342
390
  // Split on whitespace, preserving quoted strings minimally
343
391
  const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/g) ?? [];
344
392
  for (const token of tokens) {
345
393
  const clean = token.replace(/^["']|["']$/g, '').replace(/[,;]$/, '');
346
- if (
347
- clean.startsWith('/') ||
348
- clean.startsWith('~/') ||
349
- clean === '~' ||
350
- clean.startsWith('./') ||
351
- clean.startsWith('../')
352
- ) {
394
+ if (isPathLike(clean)) {
353
395
  paths.push(clean);
354
396
  }
355
397
  }
@@ -357,36 +399,17 @@ function extractCandidatePaths(command: string): string[] {
357
399
  }
358
400
 
359
401
  function extractBlockedPath(output: string, cwd: string, command?: string): string | null {
360
- // bash/sh: line X: /path: Permission denied
361
- let match = output.match(
362
- /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
363
- );
364
- if (match) return normalizeBlockedPath(match[1], cwd);
365
-
366
- // ls/cat/cp: cannot open/access/stat '/path': Permission denied
367
- match = output.match(
368
- /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
369
- );
370
- if (match) return normalizeBlockedPath(match[1], cwd);
371
-
372
- // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
373
- match = output.match(
374
- /^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
375
- );
376
- if (match) return normalizeBlockedPath(match[1], cwd);
377
-
378
- // Landstrip structured error format with file field
379
- const landstripErrors = parseLandstripErrors(output);
402
+ const landstripErrors = parseLandstripErrors(output).filter(isFilesystemAccessDenied);
380
403
  for (const error of landstripErrors) {
381
404
  if (error.file) return normalizeBlockedPath(error.file, cwd);
382
405
  }
383
406
 
384
407
  // If landstrip reported an error but without a file field, try to
385
- // extract the blocked path from the command itself
408
+ // extract the blocked path from the command itself.
386
409
  if (landstripErrors.length > 0 && command) {
387
410
  const config = loadConfig(cwd);
388
411
  for (const candidate of extractCandidatePaths(command)) {
389
- const resolved = canonicalizePath(candidate);
412
+ const resolved = normalizeBlockedPath(candidate, cwd);
390
413
  if (
391
414
  matchesPattern(resolved, config.filesystem.denyRead) ||
392
415
  !matchesPattern(resolved, config.filesystem.allowRead)
@@ -396,11 +419,35 @@ function extractBlockedPath(output: string, cwd: string, command?: string): stri
396
419
  }
397
420
  }
398
421
 
422
+ // bash/sh: line X: /path: Permission denied
423
+ let match = output.match(
424
+ /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
425
+ );
426
+ if (match) return normalizePathMatch(match[1], cwd);
427
+
428
+ // ls/cat/cp: cannot open/access/stat '/path': Permission denied
429
+ match = output.match(
430
+ /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
431
+ );
432
+ if (match) return normalizePathMatch(match[1], cwd);
433
+
434
+ // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
435
+ match = output.match(
436
+ /^[a-zA-Z0-9_-]+: (\/[^:\n]+): (?:Operation not permitted|Permission denied)$/m,
437
+ );
438
+ if (match) return normalizeBlockedPath(match[1], cwd);
439
+
399
440
  return null;
400
441
  }
401
442
 
402
- function extractBlockedWritePath(output: string, cwd: string, command?: string): string | null {
403
- return extractBlockedPath(output, cwd, command);
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);
447
+ }
448
+ }
449
+
450
+ return null;
404
451
  }
405
452
 
406
453
  function parseLandstripErrors(output: string): LandstripErrorResponse[] {
@@ -419,22 +466,21 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
419
466
 
420
467
  if (
421
468
  fields.reason &&
422
- ['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
423
- fields.source
469
+ isLandstripErrorReason(fields.reason) &&
470
+ (fields.source || fields.reason === 'AccessDenied')
424
471
  ) {
425
- const error: LandstripErrorResponse = {
426
- reason: fields.reason as LandstripErrorResponse['reason'],
427
- source: fields.source,
428
- };
472
+ const error: LandstripErrorResponse = { reason: fields.reason };
429
473
 
474
+ if (fields.source) error.source = fields.source;
430
475
  if (fields.file) error.file = fields.file;
431
476
  if (fields.program) error.program = fields.program;
432
477
 
433
- if (
434
- fields.type &&
435
- ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
436
- ) {
437
- 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;
438
484
  }
439
485
 
440
486
  errors.push(error);
@@ -458,7 +504,12 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
458
504
  if (err.type) {
459
505
  parts.push(`:${err.type}`);
460
506
  }
461
- parts.push(`: ${err.source}`);
507
+ if (err.operation) {
508
+ parts.push(`:${err.operation}`);
509
+ }
510
+ if (err.source) {
511
+ parts.push(`: ${err.source}`);
512
+ }
462
513
 
463
514
  return parts.join('');
464
515
  })
@@ -1080,6 +1131,7 @@ export function createLandstripIntegration(
1080
1131
  }
1081
1132
 
1082
1133
  const blockedPath = extractBlockedPath(stderrAcc, cwd, command);
1134
+ const blockedWritePath = extractBlockedWritePath(stderrAcc, cwd);
1083
1135
  if (blockedPath && ctx.hasUI) {
1084
1136
  const config = loadConfig(cwd);
1085
1137
  const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
@@ -1090,7 +1142,10 @@ export function createLandstripIntegration(
1090
1142
  matchesPattern,
1091
1143
  );
1092
1144
 
1093
- 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) {
1094
1149
  const choice = await promptReadBlock(
1095
1150
  ctx,
1096
1151
  blockedPath,
@@ -1142,16 +1197,12 @@ export function createLandstripIntegration(
1142
1197
  }
1143
1198
  throw error;
1144
1199
  }
1145
- const outputText = result.content
1146
- .filter((content) => content.type === 'text')
1147
- .map((content) => content.text)
1148
- .join('\n');
1149
1200
  const landstripErrors = parseLandstripErrors(landstripStderr);
1150
1201
  if (landstripErrors.length > 0) {
1151
1202
  const message = formatLandstripErrors(landstripErrors);
1152
1203
  result.content.unshift({ type: 'text', text: `\n${message}\n` });
1153
1204
  }
1154
- const blockedPath = extractBlockedWritePath(outputText, ctx.cwd, params.command);
1205
+ const blockedPath = extractBlockedWritePath(landstripStderr, ctx.cwd);
1155
1206
 
1156
1207
  if (!blockedPath || !ctx.hasUI) return result;
1157
1208
 
@@ -1251,7 +1302,10 @@ export function createLandstripIntegration(
1251
1302
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
1252
1303
  sandboxEnabled = false;
1253
1304
  sandboxReady = false;
1254
- ctx.ui.notify(`landstrip 0.11.3 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
+ );
1255
1309
  return false;
1256
1310
  }
1257
1311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.11.0",
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.4"
34
+ "@jarkkojs/landstrip": "^0.11.6"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",