pi-landstrip 0.15.0 → 0.15.2

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 +180 -103
  2. package/package.json +4 -4
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
 
@@ -1124,6 +1131,29 @@ export function createLandstripIntegration(
1124
1131
  });
1125
1132
  }
1126
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
+
1127
1157
  function createLandstripBashOps(
1128
1158
  ctx: ExtensionContext,
1129
1159
  callbacks: LandstripBashCallbacks = {},
@@ -1140,119 +1170,166 @@ export function createLandstripIntegration(
1140
1170
  const landstripArgs = ['--trap-fd', '3', '-p', policy.path, shell, ...args, command];
1141
1171
 
1142
1172
  return new Promise((resolvePromise, reject) => {
1143
- let timeoutHandle: NodeJS.Timeout | undefined;
1144
- let timedOut = false;
1145
- let cleaned = false;
1146
-
1147
- const cleanup = () => {
1148
- if (cleaned) return;
1149
- cleaned = true;
1150
- if (timeoutHandle) clearTimeout(timeoutHandle);
1151
- signal?.removeEventListener('abort', onAbort);
1152
- void proxy?.stop();
1153
- rmSync(policy.dir, { recursive: true, force: true });
1154
- };
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
+ };
1155
1190
 
1156
- const child = spawn(binaryPath(), landstripArgs, {
1157
- cwd,
1158
- env: allowNetwork ? { ...process.env, ...env } : proxyEnv(env, proxy!.port),
1159
- detached: true,
1160
- stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
1161
- });
1162
-
1163
- function killChild(): void {
1164
- if (!child.pid) return;
1165
- try {
1166
- process.kill(-child.pid, 'SIGKILL');
1167
- } catch {
1168
- 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
+ }
1169
1208
  }
1170
- }
1171
1209
 
1172
- function onAbort(): void {
1173
- killChild();
1174
- }
1175
-
1176
- if (timeout !== undefined && timeout > 0) {
1177
- timeoutHandle = setTimeout(() => {
1178
- timedOut = true;
1210
+ function onAbort(): void {
1179
1211
  killChild();
1180
- }, timeout * 1000);
1181
- }
1212
+ }
1182
1213
 
1183
- signal?.addEventListener('abort', onAbort, { once: true });
1184
- let stderrAcc = '';
1185
- let errorFdAcc = '';
1186
-
1187
- child.stdout?.on('data', onData);
1188
- child.stderr?.on('data', (data: Buffer) => {
1189
- stderrAcc += data.toString('utf8');
1190
- callbacks.onStderr?.(data);
1191
- onData(data);
1192
- });
1193
- child.stdio[3]?.on('data', (data: Buffer) => {
1194
- errorFdAcc += data.toString('utf8');
1195
- callbacks.onErrorFd?.(data);
1196
- });
1197
-
1198
- child.on('error', (error) => {
1199
- cleanup();
1200
- reject(error);
1201
- });
1202
-
1203
- child.on('close', async (code) => {
1204
- cleanup();
1205
- if (signal?.aborted) {
1206
- reject(new Error('aborted'));
1207
- return;
1214
+ if (timeout !== undefined && timeout > 0) {
1215
+ timeoutHandle = setTimeout(() => {
1216
+ timedOut = true;
1217
+ killChild();
1218
+ }, timeout * 1000);
1208
1219
  }
1209
- if (timedOut) {
1210
- reject(new Error(`timeout:${timeout}`));
1211
- 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
+ }
1212
1272
  }
1213
1273
 
1214
- const errorOutput = errorFdAcc || stderrAcc;
1215
-
1216
- const blockedPath =
1217
- extractBlockedPath(errorOutput, cwd) ??
1218
- (errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
1219
- const blockedWritePath =
1220
- extractBlockedWritePath(errorOutput, cwd) ??
1221
- (errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
1222
- if (blockedPath && ctx.hasUI) {
1223
- const config = loadConfig(cwd);
1224
- const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
1225
- const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(cwd));
1226
- const isWriteAllowed = !shouldPromptForWrite(
1227
- blockedPath,
1228
- getEffectiveAllowWrite(cwd),
1229
- matchesPattern,
1230
- );
1231
-
1232
- if (blockedWritePath === blockedPath && !isWriteAllowed) {
1233
- const choice = await promptWriteBlock(ctx, blockedPath);
1234
- if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1235
- } else if (isDeniedByDenyRead || !isReadAllowed) {
1236
- const choice = await promptReadBlock(
1237
- 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(
1238
1303
  blockedPath,
1239
- isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
1304
+ getEffectiveAllowWrite(cwd),
1305
+ matchesPattern,
1240
1306
  );
1241
- if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1242
- } else if (!isWriteAllowed) {
1243
- const choice = await promptWriteBlock(ctx, blockedPath);
1244
- if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1245
- }
1246
- } else if (!blockedPath && ctx.hasUI) {
1247
- const landstripErrors = parseLandstripTraps(errorOutput);
1248
- if (landstripErrors.length > 0) {
1249
- const formatted = formatLandstripTraps(landstripErrors);
1250
- 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
+ }
1251
1328
  }
1252
- }
1253
1329
 
1254
- resolvePromise({ exitCode: code });
1255
- });
1330
+ resolvePromise({ exitCode: code });
1331
+ });
1332
+ })().catch(reject);
1256
1333
  });
1257
1334
  },
1258
1335
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -31,17 +31,17 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@earendil-works/pi-tui": "^0.78.0",
34
- "@landstrip/landstrip": "^0.15.1"
34
+ "@landstrip/landstrip": "^0.15.4"
35
35
  },
36
36
  "devDependencies": {
37
- "@earendil-works/pi-coding-agent": "^0.78.0",
37
+ "@earendil-works/pi-coding-agent": "^0.79.6",
38
38
  "@types/node": "^24.0.0",
39
39
  "oxfmt": "^0.53.0",
40
40
  "oxlint": "^1.68.0",
41
41
  "typescript": "^5.0.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@earendil-works/pi-coding-agent": "^0.78.0"
44
+ "@earendil-works/pi-coding-agent": "^0.79.6"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "@earendil-works/pi-coding-agent": {