pi-landstrip 0.11.1 → 0.11.3

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 +211 -83
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -72,15 +72,41 @@ 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;
86
+ }
87
+
88
+ interface LandstripBashCallbacks {
89
+ onStderr?: (data: Buffer) => void;
90
+ onErrorFd?: (data: Buffer) => void;
81
91
  }
82
92
 
83
- const LANDSTRIP_VERSION = [0, 11, 5] as const;
93
+ const LANDSTRIP_VERSION = [0, 11, 9] as const;
94
+ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
95
+ const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorReason>([
96
+ 'Other',
97
+ 'AccessDenied',
98
+ 'LaunchFailed',
99
+ 'SetupFailed',
100
+ 'Usage',
101
+ ]);
102
+ const LANDSTRIP_OPERATIONS = new Set<LandstripOperation>(['read', 'write']);
103
+ const LANDSTRIP_ERROR_TYPES = new Set<LandstripErrorType>([
104
+ 'filesystem',
105
+ 'network',
106
+ 'platform',
107
+ 'launch',
108
+ 'encoding',
109
+ ]);
84
110
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
85
111
 
86
112
  const DEFAULT_CONFIG: SandboxConfig = {
@@ -348,6 +374,22 @@ function normalizePathMatch(value: string, cwd: string): string | null {
348
374
  return isPathLike(value) ? normalizeBlockedPath(value, cwd) : null;
349
375
  }
350
376
 
377
+ function isFilesystemAccessDenied(error: LandstripErrorResponse): boolean {
378
+ return error.reason === 'AccessDenied' && error.type === 'filesystem';
379
+ }
380
+
381
+ function isLandstripErrorReason(value: string): value is LandstripErrorReason {
382
+ return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorReason);
383
+ }
384
+
385
+ function isLandstripOperation(value: string): value is LandstripOperation {
386
+ return LANDSTRIP_OPERATIONS.has(value as LandstripOperation);
387
+ }
388
+
389
+ function isLandstripErrorType(value: string): value is LandstripErrorType {
390
+ return LANDSTRIP_ERROR_TYPES.has(value as LandstripErrorType);
391
+ }
392
+
351
393
  function extractCandidatePaths(command: string): string[] {
352
394
  const paths: string[] = [];
353
395
  // Split on whitespace, preserving quoted strings minimally
@@ -362,32 +404,13 @@ function extractCandidatePaths(command: string): string[] {
362
404
  }
363
405
 
364
406
  function extractBlockedPath(output: string, cwd: string, command?: string): string | null {
365
- // bash/sh: line X: /path: Permission denied
366
- let match = output.match(
367
- /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
368
- );
369
- if (match) return normalizePathMatch(match[1], cwd);
370
-
371
- // ls/cat/cp: cannot open/access/stat '/path': Permission denied
372
- match = output.match(
373
- /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
374
- );
375
- if (match) return normalizePathMatch(match[1], cwd);
376
-
377
- // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
378
- match = output.match(
379
- /^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
380
- );
381
- if (match) return normalizeBlockedPath(match[1], cwd);
382
-
383
- // Landstrip structured error format with file field
384
- const landstripErrors = parseLandstripErrors(output);
407
+ const landstripErrors = parseLandstripErrors(output).filter(isFilesystemAccessDenied);
385
408
  for (const error of landstripErrors) {
386
409
  if (error.file) return normalizeBlockedPath(error.file, cwd);
387
410
  }
388
411
 
389
412
  // If landstrip reported an error but without a file field, try to
390
- // extract the blocked path from the command itself
413
+ // extract the blocked path from the command itself.
391
414
  if (landstripErrors.length > 0 && command) {
392
415
  const config = loadConfig(cwd);
393
416
  for (const candidate of extractCandidatePaths(command)) {
@@ -401,11 +424,61 @@ function extractBlockedPath(output: string, cwd: string, command?: string): stri
401
424
  }
402
425
  }
403
426
 
427
+ return extractNativeDeniedPath(output, cwd);
428
+ }
429
+
430
+ function extractNativeDeniedPath(output: string, cwd: string): string | null {
431
+ let match = output.match(/['"]([^'"\n]+)['"]:\s+(?:Operation not permitted|Permission denied)/);
432
+ if (match) return normalizePathMatch(match[1], cwd);
433
+
434
+ // bash/sh: line X: /path: Permission denied
435
+ match = output.match(
436
+ /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
437
+ );
438
+ if (match) return normalizePathMatch(match[1], cwd);
439
+
440
+ // ls/cat/cp: cannot open/access/stat '/path': Permission denied
441
+ match = output.match(
442
+ /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: (?:Operation not permitted|Permission denied)$/m,
443
+ );
444
+ if (match) return normalizePathMatch(match[1], cwd);
445
+
446
+ // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
447
+ match = output.match(
448
+ /^[a-zA-Z0-9_-]+: (\/[^:\n]+): (?:Operation not permitted|Permission denied)$/m,
449
+ );
450
+ if (match) return normalizeBlockedPath(match[1], cwd);
451
+
452
+ return null;
453
+ }
454
+
455
+ function extractNativeWriteDeniedPath(output: string, cwd: string): string | null {
456
+ let match = output.match(
457
+ /(?:[Uu]nable to create|cannot (?:create|touch|mkdir|remove|unlink|rename)|for writing)[^'"\n]*['"]([^'"\n]+)['"]:\s+(?:Operation not permitted|Permission denied)/m,
458
+ );
459
+ if (match) return normalizePathMatch(match[1], cwd);
460
+
461
+ match = output.match(
462
+ /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
463
+ );
464
+ if (match) return normalizePathMatch(match[1], cwd);
465
+
466
+ match = output.match(
467
+ /^[a-zA-Z0-9_-]+: cannot create(?: directory)? '?([^'\n]+?)'?(?: for writing)?: (?:Operation not permitted|Permission denied)$/m,
468
+ );
469
+ if (match) return normalizePathMatch(match[1], cwd);
470
+
404
471
  return null;
405
472
  }
406
473
 
407
- function extractBlockedWritePath(output: string, cwd: string, command?: string): string | null {
408
- return extractBlockedPath(output, cwd, command);
474
+ function extractBlockedWritePath(output: string, cwd: string): string | null {
475
+ for (const error of parseLandstripErrors(output).filter(isFilesystemAccessDenied)) {
476
+ if (error.file && error.operation === 'write') {
477
+ return normalizeBlockedPath(error.file, cwd);
478
+ }
479
+ }
480
+
481
+ return extractNativeWriteDeniedPath(output, cwd);
409
482
  }
410
483
 
411
484
  function parseLandstripErrors(output: string): LandstripErrorResponse[] {
@@ -424,22 +497,21 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
424
497
 
425
498
  if (
426
499
  fields.reason &&
427
- ['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
428
- fields.source
500
+ isLandstripErrorReason(fields.reason) &&
501
+ (fields.source || fields.reason === 'AccessDenied')
429
502
  ) {
430
- const error: LandstripErrorResponse = {
431
- reason: fields.reason as LandstripErrorResponse['reason'],
432
- source: fields.source,
433
- };
503
+ const error: LandstripErrorResponse = { reason: fields.reason };
434
504
 
505
+ if (fields.source) error.source = fields.source;
435
506
  if (fields.file) error.file = fields.file;
436
507
  if (fields.program) error.program = fields.program;
437
508
 
438
- if (
439
- fields.type &&
440
- ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
441
- ) {
442
- error.type = fields.type as LandstripErrorResponse['type'];
509
+ if (fields.operation && isLandstripOperation(fields.operation)) {
510
+ error.operation = fields.operation;
511
+ }
512
+
513
+ if (fields.type && isLandstripErrorType(fields.type)) {
514
+ error.type = fields.type;
443
515
  }
444
516
 
445
517
  errors.push(error);
@@ -463,7 +535,12 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
463
535
  if (err.type) {
464
536
  parts.push(`:${err.type}`);
465
537
  }
466
- parts.push(`: ${err.source}`);
538
+ if (err.operation) {
539
+ parts.push(`:${err.operation}`);
540
+ }
541
+ if (err.source) {
542
+ parts.push(`: ${err.source}`);
543
+ }
467
544
 
468
545
  return parts.join('');
469
546
  })
@@ -1003,7 +1080,7 @@ export function createLandstripIntegration(
1003
1080
 
1004
1081
  function createLandstripBashOps(
1005
1082
  ctx: ExtensionContext,
1006
- onStderr: (data: Buffer) => void = () => {},
1083
+ callbacks: LandstripBashCallbacks = {},
1007
1084
  ): BashOperations {
1008
1085
  return {
1009
1086
  async exec(command, cwd, { onData, signal, timeout, env }) {
@@ -1013,9 +1090,8 @@ export function createLandstripIntegration(
1013
1090
  const config = loadConfig(cwd);
1014
1091
  const allowNetwork = config.network.allowNetwork;
1015
1092
  const proxy = allowNetwork ? null : await startProxy(ctx, cwd);
1016
- const proxyPort = proxy ? proxy.port : null;
1017
- const policy = writePolicyFile(cwd, proxyPort);
1018
- const landstripArgs = ['-p', policy.path, shell, ...args, command];
1093
+ const policy = writePolicyFile(cwd, proxy?.port ?? null);
1094
+ const landstripArgs = ['--error-fd', '3', '-p', policy.path, shell, ...args, command];
1019
1095
 
1020
1096
  return new Promise((resolvePromise, reject) => {
1021
1097
  let timeoutHandle: NodeJS.Timeout | undefined;
@@ -1035,7 +1111,7 @@ export function createLandstripIntegration(
1035
1111
  cwd,
1036
1112
  env: allowNetwork ? { ...process.env, ...env } : proxyEnv(env, proxy!.port),
1037
1113
  detached: true,
1038
- stdio: ['ignore', 'pipe', 'pipe'],
1114
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
1039
1115
  });
1040
1116
 
1041
1117
  function killChild(): void {
@@ -1060,13 +1136,18 @@ export function createLandstripIntegration(
1060
1136
 
1061
1137
  signal?.addEventListener('abort', onAbort, { once: true });
1062
1138
  let stderrAcc = '';
1139
+ let errorFdAcc = '';
1063
1140
 
1064
1141
  child.stdout?.on('data', onData);
1065
1142
  child.stderr?.on('data', (data: Buffer) => {
1066
1143
  stderrAcc += data.toString('utf8');
1067
- onStderr(data);
1144
+ callbacks.onStderr?.(data);
1068
1145
  onData(data);
1069
1146
  });
1147
+ child.stdio[3]?.on('data', (data: Buffer) => {
1148
+ errorFdAcc += data.toString('utf8');
1149
+ callbacks.onErrorFd?.(data);
1150
+ });
1070
1151
 
1071
1152
  child.on('error', (error) => {
1072
1153
  cleanup();
@@ -1084,7 +1165,14 @@ export function createLandstripIntegration(
1084
1165
  return;
1085
1166
  }
1086
1167
 
1087
- const blockedPath = extractBlockedPath(stderrAcc, cwd, command);
1168
+ const errorOutput = errorFdAcc || stderrAcc;
1169
+
1170
+ const blockedPath =
1171
+ extractBlockedPath(errorOutput, cwd, command) ??
1172
+ (errorFdAcc ? extractBlockedPath(stderrAcc, cwd, command) : null);
1173
+ const blockedWritePath =
1174
+ extractBlockedWritePath(errorOutput, cwd) ??
1175
+ (errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
1088
1176
  if (blockedPath && ctx.hasUI) {
1089
1177
  const config = loadConfig(cwd);
1090
1178
  const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
@@ -1095,7 +1183,10 @@ export function createLandstripIntegration(
1095
1183
  matchesPattern,
1096
1184
  );
1097
1185
 
1098
- if (isDeniedByDenyRead || !isReadAllowed) {
1186
+ if (blockedWritePath === blockedPath && !isWriteAllowed) {
1187
+ const choice = await promptWriteBlock(ctx, blockedPath);
1188
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1189
+ } else if (isDeniedByDenyRead || !isReadAllowed) {
1099
1190
  const choice = await promptReadBlock(
1100
1191
  ctx,
1101
1192
  blockedPath,
@@ -1107,7 +1198,7 @@ export function createLandstripIntegration(
1107
1198
  if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1108
1199
  }
1109
1200
  } else if (!blockedPath && ctx.hasUI) {
1110
- const landstripErrors = parseLandstripErrors(stderrAcc);
1201
+ const landstripErrors = parseLandstripErrors(errorOutput);
1111
1202
  if (landstripErrors.length > 0) {
1112
1203
  const formatted = formatLandstripErrors(landstripErrors);
1113
1204
  ctx.ui.notify(`Sandbox blocked an operation: ${formatted}`, 'warning');
@@ -1128,60 +1219,94 @@ export function createLandstripIntegration(
1128
1219
  onUpdate: AgentToolUpdateCallback<BashToolDetails | undefined> | undefined,
1129
1220
  ctx: ExtensionContext,
1130
1221
  ): Promise<AgentToolResult<BashToolDetails | undefined>> {
1131
- let landstripStderr = '';
1222
+ let landstripErrorOutput = '';
1223
+ let stderrOutput = '';
1132
1224
  const sandboxedBash = createBashToolDefinition(ctx.cwd, {
1133
- operations: createLandstripBashOps(ctx, (data) => {
1134
- landstripStderr += data.toString('utf8');
1225
+ operations: createLandstripBashOps(ctx, {
1226
+ onErrorFd: (data) => {
1227
+ landstripErrorOutput += data.toString('utf8');
1228
+ },
1229
+ onStderr: (data) => {
1230
+ stderrOutput += data.toString('utf8');
1231
+ },
1135
1232
  }),
1136
1233
  shellPath: SettingsManager.create(ctx.cwd).getShellPath(),
1137
1234
  });
1138
1235
 
1139
1236
  const run = () => sandboxedBash.execute(id, params, signal, onUpdate, ctx);
1237
+ const retryWithWriteAccess = async (
1238
+ blockedPath: string,
1239
+ ): Promise<AgentToolResult<BashToolDetails | undefined> | null> => {
1240
+ if (!ctx.hasUI) return null;
1241
+
1242
+ let config = loadConfig(ctx.cwd);
1243
+ const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
1244
+ if (matchesPattern(blockedPath, config.filesystem.denyWrite)) {
1245
+ ctx.ui.notify(
1246
+ `"${blockedPath}" is blocked by denyWrite. Check:\n ${projectPath}\n ${globalPath}`,
1247
+ 'warning',
1248
+ );
1249
+ return null;
1250
+ }
1251
+
1252
+ if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(ctx.cwd), matchesPattern)) {
1253
+ const choice = await promptWriteBlock(ctx, blockedPath);
1254
+ if (choice === 'abort') return null;
1255
+ await applyWriteChoice(choice, blockedPath, ctx.cwd);
1256
+ }
1257
+
1258
+ config = loadConfig(ctx.cwd);
1259
+ if (matchesPattern(blockedPath, config.filesystem.denyWrite)) {
1260
+ ctx.ui.notify(
1261
+ `"${blockedPath}" was added to allowWrite, but denyWrite still blocks it. Check:\n ${projectPath}\n ${globalPath}`,
1262
+ 'warning',
1263
+ );
1264
+ return null;
1265
+ }
1266
+
1267
+ onUpdate?.({
1268
+ content: [
1269
+ { type: 'text', text: `\n--- Write access granted for "${blockedPath}", retrying ---\n` },
1270
+ ],
1271
+ details: {},
1272
+ });
1273
+ landstripErrorOutput = '';
1274
+ stderrOutput = '';
1275
+ return run();
1276
+ };
1277
+
1140
1278
  let result: AgentToolResult<BashToolDetails | undefined>;
1141
1279
  try {
1142
1280
  result = await run();
1143
1281
  } catch (error) {
1144
- const landstripErrors = parseLandstripErrors(landstripStderr);
1282
+ const errorText = error instanceof Error ? error.message : String(error);
1283
+ const fallbackOutput = `${stderrOutput}\n${errorText}`;
1284
+ const blockedPath =
1285
+ extractBlockedWritePath(landstripErrorOutput, ctx.cwd) ??
1286
+ extractBlockedWritePath(fallbackOutput, ctx.cwd);
1287
+ if (blockedPath) {
1288
+ const retryResult = await retryWithWriteAccess(blockedPath);
1289
+ if (retryResult) return retryResult;
1290
+ }
1291
+
1292
+ const landstripErrors = parseLandstripErrors(landstripErrorOutput || errorText);
1145
1293
  if (landstripErrors.length > 0) {
1146
1294
  throw new Error(formatLandstripErrors(landstripErrors));
1147
1295
  }
1148
1296
  throw error;
1149
1297
  }
1150
- const outputText = result.content
1151
- .filter((content) => content.type === 'text')
1152
- .map((content) => content.text)
1153
- .join('\n');
1154
- const landstripErrors = parseLandstripErrors(landstripStderr);
1298
+ const landstripErrors = parseLandstripErrors(landstripErrorOutput);
1155
1299
  if (landstripErrors.length > 0) {
1156
1300
  const message = formatLandstripErrors(landstripErrors);
1157
1301
  result.content.unshift({ type: 'text', text: `\n${message}\n` });
1158
1302
  }
1159
- const blockedPath = extractBlockedWritePath(outputText, ctx.cwd, params.command);
1160
-
1161
- if (!blockedPath || !ctx.hasUI) return result;
1162
-
1163
- const choice = await promptWriteBlock(ctx, blockedPath);
1164
- if (choice === 'abort') return result;
1165
-
1166
- await applyWriteChoice(choice, blockedPath, ctx.cwd);
1303
+ const blockedPath =
1304
+ extractBlockedWritePath(landstripErrorOutput, ctx.cwd) ??
1305
+ extractBlockedWritePath(stderrOutput, ctx.cwd);
1306
+ if (!blockedPath) return result;
1167
1307
 
1168
- const config = loadConfig(ctx.cwd);
1169
- const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
1170
- if (matchesPattern(blockedPath, config.filesystem.denyWrite)) {
1171
- ctx.ui.notify(
1172
- `"${blockedPath}" was added to allowWrite, but denyWrite still blocks it. Check:\n ${projectPath}\n ${globalPath}`,
1173
- 'warning',
1174
- );
1175
- return result;
1176
- }
1177
-
1178
- onUpdate?.({
1179
- content: [
1180
- { type: 'text', text: `\n--- Write access granted for "${blockedPath}", retrying ---\n` },
1181
- ],
1182
- details: {},
1183
- });
1184
- return run();
1308
+ const retryResult = await retryWithWriteAccess(blockedPath);
1309
+ return retryResult ?? result;
1185
1310
  }
1186
1311
 
1187
1312
  async function preflightCommandDomains(
@@ -1256,7 +1381,10 @@ export function createLandstripIntegration(
1256
1381
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
1257
1382
  sandboxEnabled = false;
1258
1383
  sandboxReady = false;
1259
- ctx.ui.notify(`landstrip 0.11.5 or newer is required; found: ${version}`, 'error');
1384
+ ctx.ui.notify(
1385
+ `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
1386
+ 'error',
1387
+ );
1260
1388
  return false;
1261
1389
  }
1262
1390
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
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.9"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",