pi-landstrip 0.16.4 → 0.16.5

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 +110 -32
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -1247,9 +1247,114 @@ export function createLandstripIntegration(
1247
1247
  callbacks.onStderr?.(data);
1248
1248
  onData(data);
1249
1249
  });
1250
+ let trapBuffer = '';
1251
+ let queryChain: Promise<void> = Promise.resolve();
1252
+
1253
+ const respondQuery = (queryId: number, action: 'allow' | 'deny'): void => {
1254
+ if (trapSocket.destroyed) return;
1255
+ trapSocket.write(JSON.stringify({ query_id: queryId, action }) + '\n');
1256
+ };
1257
+
1258
+ // Surface a denial through the error-fd accumulator so the post-close
1259
+ // notify and the runBashWithOptionalRetry prompt/retry paths still work.
1260
+ const appendErrorLine = (line: string): void => {
1261
+ const infoLine = line + '\n';
1262
+ errorFdAcc += infoLine;
1263
+ callbacks.onErrorFd?.(Buffer.from(infoLine, 'utf8'));
1264
+ };
1265
+
1266
+ // Answer a landstrip query (state:"query"). The broker suspends the
1267
+ // child's syscall until we respond allow/deny on the trap socket.
1268
+ const handleQuery = (
1269
+ queryId: number,
1270
+ operation: LandstripOperation,
1271
+ rawPath: string,
1272
+ rawLine: string,
1273
+ ): void => {
1274
+ const path = normalizeBlockedPath(rawPath, cwd);
1275
+ const config = loadConfig(cwd);
1276
+ const isAllowed = (cfg: SandboxConfig): boolean =>
1277
+ operation === 'read'
1278
+ ? !matchesPattern(path, cfg.filesystem.denyRead) &&
1279
+ matchesPattern(path, getEffectiveAllowRead(cfg))
1280
+ : !matchesPattern(path, cfg.filesystem.denyWrite) &&
1281
+ !shouldPromptForWrite(path, getEffectiveAllowWrite(cfg));
1282
+
1283
+ if (isAllowed(config)) {
1284
+ respondQuery(queryId, 'allow');
1285
+ return;
1286
+ }
1287
+ // Without an interactive prompt, deny and let the retry path grant.
1288
+ if (!ctx.hasUI || !callbacks.promptOnBlock) {
1289
+ appendErrorLine(rawLine);
1290
+ respondQuery(queryId, 'deny');
1291
+ return;
1292
+ }
1293
+ // denyWrite is a hard block: never prompt to override it.
1294
+ if (operation === 'write' && matchesPattern(path, config.filesystem.denyWrite)) {
1295
+ respondQuery(queryId, 'deny');
1296
+ return;
1297
+ }
1298
+ // Serialize prompts so concurrent queries never overlap on screen and
1299
+ // a path granted by one prompt auto-allows later queries for it.
1300
+ queryChain = queryChain
1301
+ .then(async () => {
1302
+ const cfg = loadConfig(cwd);
1303
+ if (isAllowed(cfg)) {
1304
+ respondQuery(queryId, 'allow');
1305
+ return;
1306
+ }
1307
+ const choice =
1308
+ operation === 'read'
1309
+ ? await promptReadBlock(
1310
+ ctx,
1311
+ path,
1312
+ matchesPattern(path, cfg.filesystem.denyRead)
1313
+ ? 'granting allowRead will override it'
1314
+ : undefined,
1315
+ )
1316
+ : await promptWriteBlock(ctx, path);
1317
+ if (choice === 'abort') {
1318
+ respondQuery(queryId, 'deny');
1319
+ return;
1320
+ }
1321
+ if (operation === 'read') await applyReadChoice(choice, path, cwd);
1322
+ else await applyWriteChoice(choice, path, cwd);
1323
+ respondQuery(queryId, 'allow');
1324
+ })
1325
+ .catch(() => respondQuery(queryId, 'deny'));
1326
+ };
1327
+
1250
1328
  trapSocket.on('data', (data: Buffer) => {
1251
- errorFdAcc += data.toString('utf8');
1252
- callbacks.onErrorFd?.(data);
1329
+ trapBuffer += data.toString('utf8');
1330
+ let nl = trapBuffer.indexOf('\n');
1331
+ while (nl !== -1) {
1332
+ const line = trapBuffer.slice(0, nl);
1333
+ trapBuffer = trapBuffer.slice(nl + 1);
1334
+ nl = trapBuffer.indexOf('\n');
1335
+ if (line.length === 0) continue;
1336
+ let obj: Record<string, unknown> | null = null;
1337
+ try {
1338
+ const parsed: unknown = JSON.parse(line);
1339
+ if (typeof parsed === 'object' && parsed !== null) {
1340
+ obj = parsed as Record<string, unknown>;
1341
+ }
1342
+ } catch {
1343
+ obj = null;
1344
+ }
1345
+ if (
1346
+ obj &&
1347
+ obj.state === 'query' &&
1348
+ typeof obj.query_id === 'number' &&
1349
+ (obj.operation === 'read' || obj.operation === 'write') &&
1350
+ typeof obj.path === 'string'
1351
+ ) {
1352
+ handleQuery(obj.query_id, obj.operation, obj.path, line);
1353
+ } else {
1354
+ // Informational trap (network denials, etc.): keep for post-close handling.
1355
+ appendErrorLine(line);
1356
+ }
1357
+ }
1253
1358
  });
1254
1359
 
1255
1360
  child.on('error', (error) => {
@@ -1271,39 +1376,12 @@ export function createLandstripIntegration(
1271
1376
 
1272
1377
  const errorOutput = errorFdAcc || stderrAcc;
1273
1378
 
1379
+ // Filesystem denials are now answered live during execution; only
1380
+ // informational traps (network, etc.) remain to surface here.
1274
1381
  const blockedPath =
1275
1382
  extractBlockedPath(errorOutput, cwd) ??
1276
1383
  (errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
1277
- const blockedWritePath =
1278
- extractBlockedWritePath(errorOutput, cwd) ??
1279
- (errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
1280
- if (blockedPath && ctx.hasUI && callbacks.promptOnBlock) {
1281
- const config = loadConfig(cwd);
1282
- const isDeniedByDenyRead = matchesPattern(
1283
- blockedPath,
1284
- config.filesystem.denyRead,
1285
- );
1286
- const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(config));
1287
- const isWriteAllowed = !shouldPromptForWrite(
1288
- blockedPath,
1289
- getEffectiveAllowWrite(config),
1290
- );
1291
-
1292
- if (blockedWritePath === blockedPath && !isWriteAllowed) {
1293
- const choice = await promptWriteBlock(ctx, blockedPath);
1294
- if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1295
- } else if (isDeniedByDenyRead || !isReadAllowed) {
1296
- const choice = await promptReadBlock(
1297
- ctx,
1298
- blockedPath,
1299
- isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
1300
- );
1301
- if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1302
- } else if (!isWriteAllowed) {
1303
- const choice = await promptWriteBlock(ctx, blockedPath);
1304
- if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1305
- }
1306
- } else if (!blockedPath && ctx.hasUI) {
1384
+ if (!blockedPath && ctx.hasUI) {
1307
1385
  const landstripErrors = parseLandstripTraps(errorOutput);
1308
1386
  if (landstripErrors.length > 0) {
1309
1387
  const formatted = formatLandstripTraps(landstripErrors);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.16.4",
3
+ "version": "0.16.5",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",