pi-landstrip 0.14.2 → 0.15.1

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 +225 -145
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -24,7 +24,13 @@ import {
24
24
  rmSync,
25
25
  writeFileSync,
26
26
  } from 'node:fs';
27
- import { type AddressInfo, connect as connectNet, createServer, type Socket } from 'node:net';
27
+ import {
28
+ type AddressInfo,
29
+ connect as connectNet,
30
+ createServer,
31
+ type Socket,
32
+ Socket as NetSocket,
33
+ } from 'node:net';
28
34
  import { homedir, tmpdir } from 'node:os';
29
35
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
30
36
  import { URL } from 'node:url';
@@ -39,6 +45,7 @@ import {
39
45
  withFileMutationQueue,
40
46
  } from '@earendil-works/pi-coding-agent';
41
47
  import { Key, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
48
+ import { randomBytes } from 'node:crypto';
42
49
 
43
50
  interface SandboxFilesystemConfig {
44
51
  denyRead: string[];
@@ -90,7 +97,7 @@ interface LandstripBashCallbacks {
90
97
  onErrorFd?: (data: Buffer) => void;
91
98
  }
92
99
 
93
- const LANDSTRIP_VERSION = [0, 14, 5] as const;
100
+ const LANDSTRIP_VERSION = [0, 15, 4] as const;
94
101
  const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
95
102
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
96
103
 
@@ -464,53 +471,56 @@ function extractBlockedReadPath(output: string, cwd: string): string | null {
464
471
  return extractNativeDeniedPath(output, cwd);
465
472
  }
466
473
 
467
- // Returns the first `length` elements of a string tuple, or null if `value` is
468
- // not an array of at least that many strings.
469
- function stringTuple(value: unknown, length: number): string[] | null {
470
- if (!Array.isArray(value) || value.length < length) return null;
471
- const head = value.slice(0, length);
472
- return head.every((item) => typeof item === 'string') ? (head as string[]) : null;
474
+ function asString(value: unknown): string | null {
475
+ return typeof value === 'string' ? value : null;
473
476
  }
474
477
 
475
- // landstrip emits each trap as a serde externally-tagged enum: a single-key
476
- // object whose key is the variant name (`Filesystem`, `Network`, `Launch`,
477
- // `Usage`, `Internal`) and whose value is the variant payload.
478
+ // landstrip emits each trap as a flat JSON record tagged by a `kind`
479
+ // discriminant (`filesystem`, `network`, `launch`, `usage`, `internal`)
480
+ // alongside a stable `code` and variant-specific fields.
478
481
  function parseLandstripTrap(obj: Record<string, unknown>): LandstripTrap | null {
479
- const fs = stringTuple(obj.Filesystem, 3);
480
- if (fs) {
481
- const [operation, file, mechanism] = fs;
482
- if (operation === 'read' || operation === 'write') {
483
- return { kind: 'filesystem', operation, file, mechanism };
482
+ switch (obj.kind) {
483
+ case 'filesystem': {
484
+ const operation = obj.operation;
485
+ const file = asString(obj.path);
486
+ if ((operation === 'read' || operation === 'write') && file !== null) {
487
+ return { kind: 'filesystem', operation, file, mechanism: asString(obj.mechanism) ?? '' };
488
+ }
489
+ return null;
484
490
  }
485
- return null;
486
- }
487
-
488
- const net = stringTuple(obj.Network, 3);
489
- if (net) {
490
- const [operation, target, mechanism] = net;
491
- return { kind: 'network', operation, target, mechanism };
492
- }
493
-
494
- const launch = stringTuple(obj.Launch, 2);
495
- if (launch) {
496
- const [program, source] = launch;
497
- return { kind: 'launch', program, source };
498
- }
499
-
500
- if (typeof obj.Usage === 'string') {
501
- return { kind: 'usage', message: obj.Usage };
502
- }
503
-
504
- const internal = obj.Internal;
505
- if (typeof internal === 'object' && internal !== null && !Array.isArray(internal)) {
506
- const detail: Record<string, string> = {};
507
- for (const [key, value] of Object.entries(internal)) {
508
- if (typeof value === 'string') detail[key] = value;
491
+ case 'network': {
492
+ const operation = asString(obj.operation);
493
+ const target = asString(obj.target);
494
+ if (operation !== null && target !== null) {
495
+ return { kind: 'network', operation, target, mechanism: asString(obj.mechanism) ?? '' };
496
+ }
497
+ return null;
498
+ }
499
+ case 'launch': {
500
+ const program = asString(obj.program);
501
+ const source = asString(obj.message);
502
+ if (program !== null && source !== null) {
503
+ return { kind: 'launch', program, source };
504
+ }
505
+ return null;
509
506
  }
510
- return { kind: 'internal', detail };
507
+ case 'usage': {
508
+ const message = asString(obj.message);
509
+ return message !== null ? { kind: 'usage', message } : null;
510
+ }
511
+ case 'internal': {
512
+ const detail: Record<string, string> = {};
513
+ const raw = obj.detail;
514
+ if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
515
+ for (const [key, value] of Object.entries(raw)) {
516
+ if (typeof value === 'string') detail[key] = value;
517
+ }
518
+ }
519
+ return { kind: 'internal', detail };
520
+ }
521
+ default:
522
+ return null;
511
523
  }
512
-
513
- return null;
514
524
  }
515
525
 
516
526
  function parseLandstripTraps(output: string): LandstripTrap[] {
@@ -1121,6 +1131,29 @@ export function createLandstripIntegration(
1121
1131
  });
1122
1132
  }
1123
1133
 
1134
+ function createSocketPair(): Promise<[NetSocket, NetSocket]> {
1135
+ return new Promise((resolve, reject) => {
1136
+ const sockPath = join(tmpdir(), `.landstrip-sock-${randomBytes(8).toString('hex')}`);
1137
+ const server = createServer();
1138
+ server.on('error', reject);
1139
+ let client: NetSocket | null = null;
1140
+ server.on('connection', (serverEnd) => {
1141
+ server.close();
1142
+ try {
1143
+ rmSync(sockPath, { force: true });
1144
+ } catch {
1145
+ /* ok */
1146
+ }
1147
+ if (client) resolve([client, serverEnd]);
1148
+ });
1149
+ server.listen(sockPath, () => {
1150
+ client = new NetSocket();
1151
+ client.on('error', reject);
1152
+ client.connect(sockPath);
1153
+ });
1154
+ });
1155
+ }
1156
+
1124
1157
  function createLandstripBashOps(
1125
1158
  ctx: ExtensionContext,
1126
1159
  callbacks: LandstripBashCallbacks = {},
@@ -1137,119 +1170,166 @@ export function createLandstripIntegration(
1137
1170
  const landstripArgs = ['--trap-fd', '3', '-p', policy.path, shell, ...args, command];
1138
1171
 
1139
1172
  return new Promise((resolvePromise, reject) => {
1140
- let timeoutHandle: NodeJS.Timeout | undefined;
1141
- let timedOut = false;
1142
- let cleaned = false;
1143
-
1144
- const cleanup = () => {
1145
- if (cleaned) return;
1146
- cleaned = true;
1147
- if (timeoutHandle) clearTimeout(timeoutHandle);
1148
- signal?.removeEventListener('abort', onAbort);
1149
- void proxy?.stop();
1150
- rmSync(policy.dir, { recursive: true, force: true });
1151
- };
1173
+ (async () => {
1174
+ let timeoutHandle: NodeJS.Timeout | undefined;
1175
+ let timedOut = false;
1176
+ let cleaned = false;
1177
+
1178
+ // Create socketpair for bidirectional query-response on fd 3.
1179
+ const [trapSocket, childEnd] = await createSocketPair();
1180
+
1181
+ const cleanup = () => {
1182
+ if (cleaned) return;
1183
+ cleaned = true;
1184
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1185
+ signal?.removeEventListener('abort', onAbort);
1186
+ void proxy?.stop();
1187
+ trapSocket.destroy();
1188
+ rmSync(policy.dir, { recursive: true, force: true });
1189
+ };
1152
1190
 
1153
- const child = spawn(binaryPath(), landstripArgs, {
1154
- cwd,
1155
- env: allowNetwork ? { ...process.env, ...env } : proxyEnv(env, proxy!.port),
1156
- detached: true,
1157
- stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
1158
- });
1159
-
1160
- function killChild(): void {
1161
- if (!child.pid) return;
1162
- try {
1163
- process.kill(-child.pid, 'SIGKILL');
1164
- } catch {
1165
- child.kill('SIGKILL');
1191
+ const child = spawn(binaryPath(), landstripArgs, {
1192
+ cwd,
1193
+ env: allowNetwork ? { ...process.env, ...env } : proxyEnv(env, proxy!.port),
1194
+ detached: true,
1195
+ stdio: ['ignore', 'pipe', 'pipe', childEnd],
1196
+ });
1197
+
1198
+ // Child has dup'd its end; parent can close its copy.
1199
+ childEnd.destroy();
1200
+
1201
+ function killChild(): void {
1202
+ if (!child.pid) return;
1203
+ try {
1204
+ process.kill(-child.pid, 'SIGKILL');
1205
+ } catch {
1206
+ child.kill('SIGKILL');
1207
+ }
1166
1208
  }
1167
- }
1168
1209
 
1169
- function onAbort(): void {
1170
- killChild();
1171
- }
1172
-
1173
- if (timeout !== undefined && timeout > 0) {
1174
- timeoutHandle = setTimeout(() => {
1175
- timedOut = true;
1210
+ function onAbort(): void {
1176
1211
  killChild();
1177
- }, timeout * 1000);
1178
- }
1212
+ }
1179
1213
 
1180
- signal?.addEventListener('abort', onAbort, { once: true });
1181
- let stderrAcc = '';
1182
- let errorFdAcc = '';
1183
-
1184
- child.stdout?.on('data', onData);
1185
- child.stderr?.on('data', (data: Buffer) => {
1186
- stderrAcc += data.toString('utf8');
1187
- callbacks.onStderr?.(data);
1188
- onData(data);
1189
- });
1190
- child.stdio[3]?.on('data', (data: Buffer) => {
1191
- errorFdAcc += data.toString('utf8');
1192
- callbacks.onErrorFd?.(data);
1193
- });
1194
-
1195
- child.on('error', (error) => {
1196
- cleanup();
1197
- reject(error);
1198
- });
1199
-
1200
- child.on('close', async (code) => {
1201
- cleanup();
1202
- if (signal?.aborted) {
1203
- reject(new Error('aborted'));
1204
- return;
1214
+ if (timeout !== undefined && timeout > 0) {
1215
+ timeoutHandle = setTimeout(() => {
1216
+ timedOut = true;
1217
+ killChild();
1218
+ }, timeout * 1000);
1205
1219
  }
1206
- if (timedOut) {
1207
- reject(new Error(`timeout:${timeout}`));
1208
- return;
1220
+
1221
+ signal?.addEventListener('abort', onAbort, { once: true });
1222
+ const resolvedQueryIds = new Set<number>();
1223
+ let stderrAcc = '';
1224
+ let errorFdAcc = '';
1225
+
1226
+ child.stdout?.on('data', onData);
1227
+ child.stderr?.on('data', (data: Buffer) => {
1228
+ stderrAcc += data.toString('utf8');
1229
+ callbacks.onStderr?.(data);
1230
+ onData(data);
1231
+ });
1232
+ trapSocket.on('data', (data: Buffer) => {
1233
+ errorFdAcc += data.toString('utf8');
1234
+ callbacks.onErrorFd?.(data);
1235
+ // Process query traps in real-time.
1236
+ if (ctx.hasUI) {
1237
+ const traps = parseLandstripTraps(errorFdAcc);
1238
+ for (const trap of traps) {
1239
+ if (
1240
+ trap.kind === 'filesystem' &&
1241
+ trap.operation === 'write' &&
1242
+ 'state' in trap &&
1243
+ (trap as any).state === 'query' &&
1244
+ 'query_id' in trap
1245
+ ) {
1246
+ const queryId = (trap as any).query_id as number;
1247
+ if (!resolvedQueryIds.has(queryId)) {
1248
+ resolvedQueryIds.add(queryId);
1249
+ handleWriteQuery(trapSocket, queryId, trap.file, cwd, ctx).catch(() => {});
1250
+ }
1251
+ }
1252
+ }
1253
+ }
1254
+ });
1255
+
1256
+ async function handleWriteQuery(
1257
+ socket: NetSocket,
1258
+ queryId: number,
1259
+ file: string,
1260
+ cwd: string,
1261
+ ctx: ExtensionContext,
1262
+ ): Promise<void> {
1263
+ const choice = await promptWriteBlock(ctx, file);
1264
+ if (choice !== 'abort') {
1265
+ await applyWriteChoice(choice, file, cwd);
1266
+ }
1267
+ const action = choice === 'abort' ? 'deny' : 'allow';
1268
+ const response = JSON.stringify({ query_id: queryId, action }) + '\n';
1269
+ if (!socket.destroyed) {
1270
+ socket.write(response);
1271
+ }
1209
1272
  }
1210
1273
 
1211
- const errorOutput = errorFdAcc || stderrAcc;
1212
-
1213
- const blockedPath =
1214
- extractBlockedPath(errorOutput, cwd) ??
1215
- (errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
1216
- const blockedWritePath =
1217
- extractBlockedWritePath(errorOutput, cwd) ??
1218
- (errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
1219
- if (blockedPath && ctx.hasUI) {
1220
- const config = loadConfig(cwd);
1221
- const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
1222
- const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(cwd));
1223
- const isWriteAllowed = !shouldPromptForWrite(
1224
- blockedPath,
1225
- getEffectiveAllowWrite(cwd),
1226
- matchesPattern,
1227
- );
1228
-
1229
- if (blockedWritePath === blockedPath && !isWriteAllowed) {
1230
- const choice = await promptWriteBlock(ctx, blockedPath);
1231
- if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1232
- } else if (isDeniedByDenyRead || !isReadAllowed) {
1233
- const choice = await promptReadBlock(
1234
- ctx,
1274
+ child.on('error', (error) => {
1275
+ cleanup();
1276
+ reject(error);
1277
+ });
1278
+
1279
+ child.on('close', async (code) => {
1280
+ cleanup();
1281
+ if (signal?.aborted) {
1282
+ reject(new Error('aborted'));
1283
+ return;
1284
+ }
1285
+ if (timedOut) {
1286
+ reject(new Error(`timeout:${timeout}`));
1287
+ return;
1288
+ }
1289
+
1290
+ const errorOutput = errorFdAcc || stderrAcc;
1291
+
1292
+ const blockedPath =
1293
+ extractBlockedPath(errorOutput, cwd) ??
1294
+ (errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
1295
+ const blockedWritePath =
1296
+ extractBlockedWritePath(errorOutput, cwd) ??
1297
+ (errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
1298
+ if (blockedPath && ctx.hasUI) {
1299
+ const config = loadConfig(cwd);
1300
+ const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
1301
+ const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(cwd));
1302
+ const isWriteAllowed = !shouldPromptForWrite(
1235
1303
  blockedPath,
1236
- isDeniedByDenyRead ? 'denyRead overrides allowRead' : undefined,
1304
+ getEffectiveAllowWrite(cwd),
1305
+ matchesPattern,
1237
1306
  );
1238
- if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1239
- } else if (!isWriteAllowed) {
1240
- const choice = await promptWriteBlock(ctx, blockedPath);
1241
- if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1242
- }
1243
- } else if (!blockedPath && ctx.hasUI) {
1244
- const landstripErrors = parseLandstripTraps(errorOutput);
1245
- if (landstripErrors.length > 0) {
1246
- const formatted = formatLandstripTraps(landstripErrors);
1247
- notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
1307
+
1308
+ if (blockedWritePath === blockedPath && !isWriteAllowed) {
1309
+ const choice = await promptWriteBlock(ctx, blockedPath);
1310
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1311
+ } else if (isDeniedByDenyRead || !isReadAllowed) {
1312
+ const choice = await promptReadBlock(
1313
+ ctx,
1314
+ blockedPath,
1315
+ isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
1316
+ );
1317
+ if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1318
+ } else if (!isWriteAllowed) {
1319
+ const choice = await promptWriteBlock(ctx, blockedPath);
1320
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1321
+ }
1322
+ } else if (!blockedPath && ctx.hasUI) {
1323
+ const landstripErrors = parseLandstripTraps(errorOutput);
1324
+ if (landstripErrors.length > 0) {
1325
+ const formatted = formatLandstripTraps(landstripErrors);
1326
+ notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
1327
+ }
1248
1328
  }
1249
- }
1250
1329
 
1251
- resolvePromise({ exitCode: code });
1252
- });
1330
+ resolvePromise({ exitCode: code });
1331
+ });
1332
+ })().catch(reject);
1253
1333
  });
1254
1334
  },
1255
1335
  };
@@ -1331,7 +1411,7 @@ export function createLandstripIntegration(
1331
1411
  ctx,
1332
1412
  blockedPath,
1333
1413
  matchesPattern(blockedPath, config.filesystem.denyRead)
1334
- ? 'denyRead overrides allowRead'
1414
+ ? 'granting allowRead will override it'
1335
1415
  : undefined,
1336
1416
  );
1337
1417
  if (choice === 'abort') return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
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.4"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",