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.
- package/index.ts +225 -145
- 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 {
|
|
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
|
|
|
@@ -464,53 +471,56 @@ function extractBlockedReadPath(output: string, cwd: string): string | null {
|
|
|
464
471
|
return extractNativeDeniedPath(output, cwd);
|
|
465
472
|
}
|
|
466
473
|
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
476
|
-
//
|
|
477
|
-
//
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
-
|
|
1170
|
-
killChild();
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
if (timeout !== undefined && timeout > 0) {
|
|
1174
|
-
timeoutHandle = setTimeout(() => {
|
|
1175
|
-
timedOut = true;
|
|
1210
|
+
function onAbort(): void {
|
|
1176
1211
|
killChild();
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1212
|
+
}
|
|
1179
1213
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
1304
|
+
getEffectiveAllowWrite(cwd),
|
|
1305
|
+
matchesPattern,
|
|
1237
1306
|
);
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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
|
-
|
|
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
|
-
? '
|
|
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.
|
|
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.
|
|
34
|
+
"@landstrip/landstrip": "^0.15.4"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@earendil-works/pi-coding-agent": "^0.78.0",
|