pi-landstrip 0.14.1 → 0.15.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.
Files changed (2) hide show
  1. package/index.ts +105 -47
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -454,53 +454,66 @@ function extractBlockedWritePath(output: string, cwd: string): string | null {
454
454
  return extractNativeWriteDeniedPath(output, cwd);
455
455
  }
456
456
 
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
- }
464
-
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 };
457
+ function extractBlockedReadPath(output: string, cwd: string): string | null {
458
+ for (const error of parseLandstripTraps(output).filter(isFilesystemTrap)) {
459
+ if (error.operation === 'read') {
460
+ return normalizeBlockedPath(error.file, cwd);
474
461
  }
475
- return null;
476
462
  }
477
463
 
478
- const net = stringTuple(obj.Network, 3);
479
- if (net) {
480
- const [operation, target, mechanism] = net;
481
- return { kind: 'network', operation, target, mechanism };
482
- }
483
-
484
- const launch = stringTuple(obj.Launch, 2);
485
- if (launch) {
486
- const [program, source] = launch;
487
- return { kind: 'launch', program, source };
488
- }
464
+ return extractNativeDeniedPath(output, cwd);
465
+ }
489
466
 
490
- if (typeof obj.Usage === 'string') {
491
- return { kind: 'usage', message: obj.Usage };
492
- }
467
+ function asString(value: unknown): string | null {
468
+ return typeof value === 'string' ? value : null;
469
+ }
493
470
 
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;
471
+ // landstrip emits each trap as a flat JSON record tagged by a `kind`
472
+ // discriminant (`filesystem`, `network`, `launch`, `usage`, `internal`)
473
+ // alongside a stable `code` and variant-specific fields.
474
+ function parseLandstripTrap(obj: Record<string, unknown>): LandstripTrap | null {
475
+ switch (obj.kind) {
476
+ case 'filesystem': {
477
+ const operation = obj.operation;
478
+ const file = asString(obj.path);
479
+ if ((operation === 'read' || operation === 'write') && file !== null) {
480
+ return { kind: 'filesystem', operation, file, mechanism: asString(obj.mechanism) ?? '' };
481
+ }
482
+ return null;
483
+ }
484
+ case 'network': {
485
+ const operation = asString(obj.operation);
486
+ const target = asString(obj.target);
487
+ if (operation !== null && target !== null) {
488
+ return { kind: 'network', operation, target, mechanism: asString(obj.mechanism) ?? '' };
489
+ }
490
+ return null;
491
+ }
492
+ case 'launch': {
493
+ const program = asString(obj.program);
494
+ const source = asString(obj.message);
495
+ if (program !== null && source !== null) {
496
+ return { kind: 'launch', program, source };
497
+ }
498
+ return null;
499
499
  }
500
- return { kind: 'internal', detail };
500
+ case 'usage': {
501
+ const message = asString(obj.message);
502
+ return message !== null ? { kind: 'usage', message } : null;
503
+ }
504
+ case 'internal': {
505
+ const detail: Record<string, string> = {};
506
+ const raw = obj.detail;
507
+ if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
508
+ for (const [key, value] of Object.entries(raw)) {
509
+ if (typeof value === 'string') detail[key] = value;
510
+ }
511
+ }
512
+ return { kind: 'internal', detail };
513
+ }
514
+ default:
515
+ return null;
501
516
  }
502
-
503
- return null;
504
517
  }
505
518
 
506
519
  function parseLandstripTraps(output: string): LandstripTrap[] {
@@ -1223,7 +1236,7 @@ export function createLandstripIntegration(
1223
1236
  const choice = await promptReadBlock(
1224
1237
  ctx,
1225
1238
  blockedPath,
1226
- isDeniedByDenyRead ? 'denyRead overrides allowRead' : undefined,
1239
+ isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
1227
1240
  );
1228
1241
  if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1229
1242
  } else if (!isWriteAllowed) {
@@ -1310,17 +1323,54 @@ export function createLandstripIntegration(
1310
1323
  return run();
1311
1324
  };
1312
1325
 
1326
+ const retryWithReadAccess = async (
1327
+ blockedPath: string,
1328
+ ): Promise<AgentToolResult<BashToolDetails | undefined> | null> => {
1329
+ if (!ctx.hasUI) return null;
1330
+
1331
+ if (!matchesPattern(blockedPath, getEffectiveAllowRead(ctx.cwd))) {
1332
+ const config = loadConfig(ctx.cwd);
1333
+ const choice = await promptReadBlock(
1334
+ ctx,
1335
+ blockedPath,
1336
+ matchesPattern(blockedPath, config.filesystem.denyRead)
1337
+ ? 'granting allowRead will override it'
1338
+ : undefined,
1339
+ );
1340
+ if (choice === 'abort') return null;
1341
+ await applyReadChoice(choice, blockedPath, ctx.cwd);
1342
+ }
1343
+
1344
+ onUpdate?.({
1345
+ content: [
1346
+ { type: 'text', text: `\n--- Read access granted for "${blockedPath}", retrying ---\n` },
1347
+ ],
1348
+ details: {},
1349
+ });
1350
+ landstripErrorOutput = '';
1351
+ stderrOutput = '';
1352
+ return run();
1353
+ };
1354
+
1313
1355
  let result: AgentToolResult<BashToolDetails | undefined>;
1314
1356
  try {
1315
1357
  result = await run();
1316
1358
  } catch (error) {
1317
1359
  const errorText = error instanceof Error ? error.message : String(error);
1318
1360
  const fallbackOutput = `${stderrOutput}\n${errorText}`;
1319
- const blockedPath =
1361
+ const blockedWritePath =
1320
1362
  extractBlockedWritePath(landstripErrorOutput, ctx.cwd) ??
1321
1363
  extractBlockedWritePath(fallbackOutput, ctx.cwd);
1322
- if (blockedPath) {
1323
- const retryResult = await retryWithWriteAccess(blockedPath);
1364
+ if (blockedWritePath) {
1365
+ const retryResult = await retryWithWriteAccess(blockedWritePath);
1366
+ if (retryResult) return retryResult;
1367
+ }
1368
+
1369
+ const blockedReadPath =
1370
+ extractBlockedReadPath(landstripErrorOutput, ctx.cwd) ??
1371
+ extractBlockedReadPath(fallbackOutput, ctx.cwd);
1372
+ if (blockedReadPath) {
1373
+ const retryResult = await retryWithReadAccess(blockedReadPath);
1324
1374
  if (retryResult) return retryResult;
1325
1375
  }
1326
1376
 
@@ -1335,12 +1385,20 @@ export function createLandstripIntegration(
1335
1385
  const message = formatLandstripTraps(landstripErrors);
1336
1386
  result.content.unshift({ type: 'text', text: `\n${message}\n` });
1337
1387
  }
1338
- const blockedPath =
1388
+ const blockedWritePath =
1339
1389
  extractBlockedWritePath(landstripErrorOutput, ctx.cwd) ??
1340
1390
  extractBlockedWritePath(stderrOutput, ctx.cwd);
1341
- if (!blockedPath) return result;
1391
+ if (blockedWritePath) {
1392
+ const retryResult = await retryWithWriteAccess(blockedWritePath);
1393
+ if (retryResult) return retryResult;
1394
+ }
1395
+
1396
+ const blockedReadPath =
1397
+ extractBlockedReadPath(landstripErrorOutput, ctx.cwd) ??
1398
+ extractBlockedReadPath(stderrOutput, ctx.cwd);
1399
+ if (!blockedReadPath) return result;
1342
1400
 
1343
- const retryResult = await retryWithWriteAccess(blockedPath);
1401
+ const retryResult = await retryWithReadAccess(blockedReadPath);
1344
1402
  return retryResult ?? result;
1345
1403
  }
1346
1404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
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
- "@landstrip/landstrip": "^0.14.5"
34
+ "@landstrip/landstrip": "^0.15.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",