pi-landstrip 0.11.2 → 0.11.4

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 (3) hide show
  1. package/index.ts +144 -65
  2. package/package.json +2 -2
  3. package/sandbox.json +6 -46
package/index.ts CHANGED
@@ -85,7 +85,12 @@ interface LandstripErrorResponse {
85
85
  source?: string;
86
86
  }
87
87
 
88
- const LANDSTRIP_VERSION = [0, 11, 6] as const;
88
+ interface LandstripBashCallbacks {
89
+ onStderr?: (data: Buffer) => void;
90
+ onErrorFd?: (data: Buffer) => void;
91
+ }
92
+
93
+ const LANDSTRIP_VERSION = [0, 11, 9] as const;
89
94
  const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
90
95
  const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorReason>([
91
96
  'Other',
@@ -111,28 +116,14 @@ const DEFAULT_CONFIG: SandboxConfig = {
111
116
  allowLocalBinding: false,
112
117
  allowAllUnixSockets: false,
113
118
  allowUnixSockets: [],
114
- allowedDomains: [
115
- 'npmjs.org',
116
- '*.npmjs.org',
117
- 'registry.npmjs.org',
118
- 'registry.yarnpkg.com',
119
- 'pypi.org',
120
- '*.pypi.org',
121
- 'github.com',
122
- '*.github.com',
123
- 'api.github.com',
124
- 'raw.githubusercontent.com',
125
- 'crates.io',
126
- '*.crates.io',
127
- 'static.crates.io',
128
- ],
119
+ allowedDomains: [],
129
120
  deniedDomains: [],
130
121
  },
131
122
  filesystem: {
132
123
  denyRead: ['/Users', '/home'],
133
- allowRead: ['.', '~/.config', '~/.gitconfig', '~/.local', '~/.cargo', '/dev/null'],
134
- allowWrite: ['.', '/tmp', '/dev/null'],
135
- denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
124
+ allowRead: ['.', '~/.gitconfig', '/dev/null'],
125
+ allowWrite: ['.', '/dev/null'],
126
+ denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
136
127
  },
137
128
  };
138
129
 
@@ -191,16 +182,30 @@ function loadConfig(cwd: string): SandboxConfig {
191
182
  return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
192
183
  }
193
184
 
185
+ function mergeArray(base: string[], override?: string[]): string[] {
186
+ if (!override) return base;
187
+ return [...new Set([...base, ...override])];
188
+ }
189
+
194
190
  function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
191
+ const network = overrides.network;
192
+ const filesystem = overrides.filesystem;
193
+
195
194
  return {
196
195
  enabled: overrides.enabled ?? base.enabled,
197
196
  network: {
198
- ...base.network,
199
- ...overrides.network,
197
+ allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
198
+ allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
199
+ allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
200
+ allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
201
+ allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
202
+ deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
200
203
  },
201
204
  filesystem: {
202
- ...base.filesystem,
203
- ...overrides.filesystem,
205
+ denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
206
+ allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
207
+ allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
208
+ denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
204
209
  },
205
210
  };
206
211
  }
@@ -419,15 +424,22 @@ function extractBlockedPath(output: string, cwd: string, command?: string): stri
419
424
  }
420
425
  }
421
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
+
422
434
  // bash/sh: line X: /path: Permission denied
423
- let match = output.match(
435
+ match = output.match(
424
436
  /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
425
437
  );
426
438
  if (match) return normalizePathMatch(match[1], cwd);
427
439
 
428
440
  // ls/cat/cp: cannot open/access/stat '/path': Permission denied
429
441
  match = output.match(
430
- /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
442
+ /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: (?:Operation not permitted|Permission denied)$/m,
431
443
  );
432
444
  if (match) return normalizePathMatch(match[1], cwd);
433
445
 
@@ -440,6 +452,25 @@ function extractBlockedPath(output: string, cwd: string, command?: string): stri
440
452
  return null;
441
453
  }
442
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
+
471
+ return null;
472
+ }
473
+
443
474
  function extractBlockedWritePath(output: string, cwd: string): string | null {
444
475
  for (const error of parseLandstripErrors(output).filter(isFilesystemAccessDenied)) {
445
476
  if (error.file && error.operation === 'write') {
@@ -447,7 +478,7 @@ function extractBlockedWritePath(output: string, cwd: string): string | null {
447
478
  }
448
479
  }
449
480
 
450
- return null;
481
+ return extractNativeWriteDeniedPath(output, cwd);
451
482
  }
452
483
 
453
484
  function parseLandstripErrors(output: string): LandstripErrorResponse[] {
@@ -1049,7 +1080,7 @@ export function createLandstripIntegration(
1049
1080
 
1050
1081
  function createLandstripBashOps(
1051
1082
  ctx: ExtensionContext,
1052
- onStderr: (data: Buffer) => void = () => {},
1083
+ callbacks: LandstripBashCallbacks = {},
1053
1084
  ): BashOperations {
1054
1085
  return {
1055
1086
  async exec(command, cwd, { onData, signal, timeout, env }) {
@@ -1059,9 +1090,8 @@ export function createLandstripIntegration(
1059
1090
  const config = loadConfig(cwd);
1060
1091
  const allowNetwork = config.network.allowNetwork;
1061
1092
  const proxy = allowNetwork ? null : await startProxy(ctx, cwd);
1062
- const proxyPort = proxy ? proxy.port : null;
1063
- const policy = writePolicyFile(cwd, proxyPort);
1064
- 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];
1065
1095
 
1066
1096
  return new Promise((resolvePromise, reject) => {
1067
1097
  let timeoutHandle: NodeJS.Timeout | undefined;
@@ -1081,7 +1111,7 @@ export function createLandstripIntegration(
1081
1111
  cwd,
1082
1112
  env: allowNetwork ? { ...process.env, ...env } : proxyEnv(env, proxy!.port),
1083
1113
  detached: true,
1084
- stdio: ['ignore', 'pipe', 'pipe'],
1114
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
1085
1115
  });
1086
1116
 
1087
1117
  function killChild(): void {
@@ -1106,13 +1136,18 @@ export function createLandstripIntegration(
1106
1136
 
1107
1137
  signal?.addEventListener('abort', onAbort, { once: true });
1108
1138
  let stderrAcc = '';
1139
+ let errorFdAcc = '';
1109
1140
 
1110
1141
  child.stdout?.on('data', onData);
1111
1142
  child.stderr?.on('data', (data: Buffer) => {
1112
1143
  stderrAcc += data.toString('utf8');
1113
- onStderr(data);
1144
+ callbacks.onStderr?.(data);
1114
1145
  onData(data);
1115
1146
  });
1147
+ child.stdio[3]?.on('data', (data: Buffer) => {
1148
+ errorFdAcc += data.toString('utf8');
1149
+ callbacks.onErrorFd?.(data);
1150
+ });
1116
1151
 
1117
1152
  child.on('error', (error) => {
1118
1153
  cleanup();
@@ -1130,8 +1165,14 @@ export function createLandstripIntegration(
1130
1165
  return;
1131
1166
  }
1132
1167
 
1133
- const blockedPath = extractBlockedPath(stderrAcc, cwd, command);
1134
- const blockedWritePath = extractBlockedWritePath(stderrAcc, cwd);
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);
1135
1176
  if (blockedPath && ctx.hasUI) {
1136
1177
  const config = loadConfig(cwd);
1137
1178
  const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
@@ -1157,7 +1198,7 @@ export function createLandstripIntegration(
1157
1198
  if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1158
1199
  }
1159
1200
  } else if (!blockedPath && ctx.hasUI) {
1160
- const landstripErrors = parseLandstripErrors(stderrAcc);
1201
+ const landstripErrors = parseLandstripErrors(errorOutput);
1161
1202
  if (landstripErrors.length > 0) {
1162
1203
  const formatted = formatLandstripErrors(landstripErrors);
1163
1204
  ctx.ui.notify(`Sandbox blocked an operation: ${formatted}`, 'warning');
@@ -1178,56 +1219,94 @@ export function createLandstripIntegration(
1178
1219
  onUpdate: AgentToolUpdateCallback<BashToolDetails | undefined> | undefined,
1179
1220
  ctx: ExtensionContext,
1180
1221
  ): Promise<AgentToolResult<BashToolDetails | undefined>> {
1181
- let landstripStderr = '';
1222
+ let landstripErrorOutput = '';
1223
+ let stderrOutput = '';
1182
1224
  const sandboxedBash = createBashToolDefinition(ctx.cwd, {
1183
- operations: createLandstripBashOps(ctx, (data) => {
1184
- 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
+ },
1185
1232
  }),
1186
1233
  shellPath: SettingsManager.create(ctx.cwd).getShellPath(),
1187
1234
  });
1188
1235
 
1189
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
+
1190
1278
  let result: AgentToolResult<BashToolDetails | undefined>;
1191
1279
  try {
1192
1280
  result = await run();
1193
1281
  } catch (error) {
1194
- 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);
1195
1293
  if (landstripErrors.length > 0) {
1196
1294
  throw new Error(formatLandstripErrors(landstripErrors));
1197
1295
  }
1198
1296
  throw error;
1199
1297
  }
1200
- const landstripErrors = parseLandstripErrors(landstripStderr);
1298
+ const landstripErrors = parseLandstripErrors(landstripErrorOutput);
1201
1299
  if (landstripErrors.length > 0) {
1202
1300
  const message = formatLandstripErrors(landstripErrors);
1203
1301
  result.content.unshift({ type: 'text', text: `\n${message}\n` });
1204
1302
  }
1205
- const blockedPath = extractBlockedWritePath(landstripStderr, ctx.cwd);
1206
-
1207
- if (!blockedPath || !ctx.hasUI) return result;
1208
-
1209
- const choice = await promptWriteBlock(ctx, blockedPath);
1210
- if (choice === 'abort') return result;
1211
-
1212
- await applyWriteChoice(choice, blockedPath, ctx.cwd);
1213
-
1214
- const config = loadConfig(ctx.cwd);
1215
- const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
1216
- if (matchesPattern(blockedPath, config.filesystem.denyWrite)) {
1217
- ctx.ui.notify(
1218
- `"${blockedPath}" was added to allowWrite, but denyWrite still blocks it. Check:\n ${projectPath}\n ${globalPath}`,
1219
- 'warning',
1220
- );
1221
- return result;
1222
- }
1303
+ const blockedPath =
1304
+ extractBlockedWritePath(landstripErrorOutput, ctx.cwd) ??
1305
+ extractBlockedWritePath(stderrOutput, ctx.cwd);
1306
+ if (!blockedPath) return result;
1223
1307
 
1224
- onUpdate?.({
1225
- content: [
1226
- { type: 'text', text: `\n--- Write access granted for "${blockedPath}", retrying ---\n` },
1227
- ],
1228
- details: {},
1229
- });
1230
- return run();
1308
+ const retryResult = await retryWithWriteAccess(blockedPath);
1309
+ return retryResult ?? result;
1231
1310
  }
1232
1311
 
1233
1312
  async function preflightCommandDomains(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
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.6"
34
+ "@jarkkojs/landstrip": "^0.11.9"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",
package/sandbox.json CHANGED
@@ -2,56 +2,16 @@
2
2
  "enabled": true,
3
3
  "network": {
4
4
  "allowNetwork": false,
5
- "allowLocalBinding": true,
5
+ "allowLocalBinding": false,
6
6
  "allowAllUnixSockets": false,
7
7
  "allowUnixSockets": [],
8
- "allowedDomains": [
9
- "github.com",
10
- "*.github.com",
11
- "api.github.com",
12
- "raw.githubusercontent.com",
13
- "objects.githubusercontent.com",
14
- "codeload.github.com",
15
- "registry.npmjs.org",
16
- "npmjs.org",
17
- "*.npmjs.org",
18
- "nodejs.org",
19
- "*.nodejs.org",
20
- "crates.io",
21
- "*.crates.io",
22
- "static.crates.io"
23
- ],
8
+ "allowedDomains": [],
24
9
  "deniedDomains": []
25
10
  },
26
11
  "filesystem": {
27
- "denyRead": ["/home"],
28
- "allowRead": [
29
- ".",
30
- "/tmp",
31
- "/var/tmp",
32
- "/dev/null",
33
- "~/.config",
34
- "~/.gitconfig",
35
- "~/.local",
36
- "~/.cargo",
37
- "~/.rustup",
38
- "~/.npm",
39
- "~/.cache",
40
- "~/.bun",
41
- "~/.node-gyp"
42
- ],
43
- "allowWrite": [
44
- ".",
45
- "/tmp",
46
- "/var/tmp",
47
- "/dev/null",
48
- "~/.cargo",
49
- "~/.rustup",
50
- "~/.npm",
51
- "~/.cache",
52
- "~/.bun",
53
- "~/.node-gyp"
54
- ],
55
- "denyWrite": [".env", ".env.*", "*.pem", "*.key"]
12
+ "denyRead": ["/Users", "/home"],
13
+ "allowRead": [".", "~/.gitconfig", "/dev/null"],
14
+ "allowWrite": [".", "/dev/null"],
15
+ "denyWrite": ["**/.env", "**/.env.*", "**/*.pem", "**/*.key"]
56
16
  }
57
17
  }