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.
- package/index.ts +180 -103
- 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 {
|
|
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,
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
-
|
|
1173
|
-
killChild();
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
if (timeout !== undefined && timeout > 0) {
|
|
1177
|
-
timeoutHandle = setTimeout(() => {
|
|
1178
|
-
timedOut = true;
|
|
1210
|
+
function onAbort(): void {
|
|
1179
1211
|
killChild();
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1212
|
+
}
|
|
1182
1213
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1304
|
+
getEffectiveAllowWrite(cwd),
|
|
1305
|
+
matchesPattern,
|
|
1240
1306
|
);
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
34
|
+
"@landstrip/landstrip": "^0.15.4"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@earendil-works/pi-coding-agent": "^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.
|
|
44
|
+
"@earendil-works/pi-coding-agent": "^0.79.6"
|
|
45
45
|
},
|
|
46
46
|
"peerDependenciesMeta": {
|
|
47
47
|
"@earendil-works/pi-coding-agent": {
|