pi-landstrip 0.16.3 → 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.
- package/index.ts +136 -61
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -1,19 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
|
-
import type {
|
|
5
|
-
AgentToolResult,
|
|
6
|
-
AgentToolUpdateCallback,
|
|
7
|
-
BashToolDetails,
|
|
8
|
-
BashToolInput,
|
|
9
|
-
ExtensionAPI,
|
|
10
|
-
ExtensionContext,
|
|
11
|
-
Theme,
|
|
12
|
-
} from '@earendil-works/pi-coding-agent';
|
|
13
|
-
|
|
14
|
-
import { binaryPath } from '@landstrip/landstrip';
|
|
15
|
-
|
|
16
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
17
6
|
import {
|
|
18
7
|
existsSync,
|
|
19
8
|
mkdirSync,
|
|
@@ -34,6 +23,16 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
34
23
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
35
24
|
import { URL } from 'node:url';
|
|
36
25
|
|
|
26
|
+
import type {
|
|
27
|
+
AgentToolResult,
|
|
28
|
+
AgentToolUpdateCallback,
|
|
29
|
+
BashToolDetails,
|
|
30
|
+
BashToolInput,
|
|
31
|
+
ExtensionAPI,
|
|
32
|
+
ExtensionContext,
|
|
33
|
+
Theme,
|
|
34
|
+
} from '@earendil-works/pi-coding-agent';
|
|
35
|
+
|
|
37
36
|
import {
|
|
38
37
|
type BashOperations,
|
|
39
38
|
createBashToolDefinition,
|
|
@@ -44,7 +43,7 @@ import {
|
|
|
44
43
|
withFileMutationQueue,
|
|
45
44
|
} from '@earendil-works/pi-coding-agent';
|
|
46
45
|
import { Key, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
47
|
-
import {
|
|
46
|
+
import { binaryPath } from '@landstrip/landstrip';
|
|
48
47
|
|
|
49
48
|
interface SandboxFilesystemConfig {
|
|
50
49
|
denyRead: string[];
|
|
@@ -328,12 +327,8 @@ function allowsAllDomains(allowedDomains: string[]): boolean {
|
|
|
328
327
|
return allowedDomains.includes('*');
|
|
329
328
|
}
|
|
330
329
|
|
|
331
|
-
function shouldPromptForWrite(
|
|
332
|
-
path
|
|
333
|
-
allowWrite: string[],
|
|
334
|
-
patternMatcher: (path: string, patterns: string[]) => boolean,
|
|
335
|
-
): boolean {
|
|
336
|
-
return allowWrite.length === 0 || !patternMatcher(path, allowWrite);
|
|
330
|
+
function shouldPromptForWrite(path: string, allowWrite: string[]): boolean {
|
|
331
|
+
return allowWrite.length === 0 || !matchesPattern(path, allowWrite);
|
|
337
332
|
}
|
|
338
333
|
|
|
339
334
|
function expandPath(filePath: string): string {
|
|
@@ -1004,12 +999,15 @@ export function createLandstripIntegration(
|
|
|
1004
999
|
return { dir, path };
|
|
1005
1000
|
}
|
|
1006
1001
|
|
|
1007
|
-
function startProxy(
|
|
1008
|
-
ctx: ExtensionContext,
|
|
1009
|
-
cwd: string,
|
|
1010
|
-
): Promise<{ port: number; stop: () => Promise<void> }> {
|
|
1002
|
+
function startProxy(cwd: string): Promise<{ port: number; stop: () => Promise<void> }> {
|
|
1011
1003
|
const sockets = new Set<Socket>();
|
|
1012
1004
|
|
|
1005
|
+
function domainAllowed(domain: string): boolean {
|
|
1006
|
+
const config = loadConfig(cwd);
|
|
1007
|
+
if (domainMatchesAny(domain, config.network.deniedDomains)) return false;
|
|
1008
|
+
return domainMatchesAny(domain, getEffectiveAllowedDomains(config));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1013
1011
|
async function handleConnect(client: Socket, target: string, rest: Buffer): Promise<void> {
|
|
1014
1012
|
const endpoint = splitHostPort(target, 443);
|
|
1015
1013
|
if (!endpoint || !Number.isFinite(endpoint.port)) {
|
|
@@ -1017,7 +1015,7 @@ export function createLandstripIntegration(
|
|
|
1017
1015
|
return;
|
|
1018
1016
|
}
|
|
1019
1017
|
|
|
1020
|
-
if (!(
|
|
1018
|
+
if (!domainAllowed(endpoint.host)) {
|
|
1021
1019
|
denyProxyRequest(client);
|
|
1022
1020
|
return;
|
|
1023
1021
|
}
|
|
@@ -1065,7 +1063,7 @@ export function createLandstripIntegration(
|
|
|
1065
1063
|
}
|
|
1066
1064
|
}
|
|
1067
1065
|
|
|
1068
|
-
if (!(
|
|
1066
|
+
if (!domainAllowed(url.hostname)) {
|
|
1069
1067
|
denyProxyRequest(client);
|
|
1070
1068
|
return;
|
|
1071
1069
|
}
|
|
@@ -1187,7 +1185,7 @@ export function createLandstripIntegration(
|
|
|
1187
1185
|
const { shell, args } = getShellConfig(SettingsManager.create(cwd).getShellPath());
|
|
1188
1186
|
const config = loadConfig(cwd);
|
|
1189
1187
|
const allowNetwork = config.network.allowNetwork;
|
|
1190
|
-
const proxy = allowNetwork ? null : await startProxy(
|
|
1188
|
+
const proxy = allowNetwork ? null : await startProxy(cwd);
|
|
1191
1189
|
const policy = writePolicyFile(cwd, proxy?.port ?? null);
|
|
1192
1190
|
const landstripArgs = ['--trap-fd', '3', '-p', policy.path, shell, ...args, command];
|
|
1193
1191
|
|
|
@@ -1249,9 +1247,114 @@ export function createLandstripIntegration(
|
|
|
1249
1247
|
callbacks.onStderr?.(data);
|
|
1250
1248
|
onData(data);
|
|
1251
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
|
+
|
|
1252
1328
|
trapSocket.on('data', (data: Buffer) => {
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
+
}
|
|
1255
1358
|
});
|
|
1256
1359
|
|
|
1257
1360
|
child.on('error', (error) => {
|
|
@@ -1273,40 +1376,12 @@ export function createLandstripIntegration(
|
|
|
1273
1376
|
|
|
1274
1377
|
const errorOutput = errorFdAcc || stderrAcc;
|
|
1275
1378
|
|
|
1379
|
+
// Filesystem denials are now answered live during execution; only
|
|
1380
|
+
// informational traps (network, etc.) remain to surface here.
|
|
1276
1381
|
const blockedPath =
|
|
1277
1382
|
extractBlockedPath(errorOutput, cwd) ??
|
|
1278
1383
|
(errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
|
|
1279
|
-
|
|
1280
|
-
extractBlockedWritePath(errorOutput, cwd) ??
|
|
1281
|
-
(errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
|
|
1282
|
-
if (blockedPath && ctx.hasUI && callbacks.promptOnBlock) {
|
|
1283
|
-
const config = loadConfig(cwd);
|
|
1284
|
-
const isDeniedByDenyRead = matchesPattern(
|
|
1285
|
-
blockedPath,
|
|
1286
|
-
config.filesystem.denyRead,
|
|
1287
|
-
);
|
|
1288
|
-
const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(config));
|
|
1289
|
-
const isWriteAllowed = !shouldPromptForWrite(
|
|
1290
|
-
blockedPath,
|
|
1291
|
-
getEffectiveAllowWrite(config),
|
|
1292
|
-
matchesPattern,
|
|
1293
|
-
);
|
|
1294
|
-
|
|
1295
|
-
if (blockedWritePath === blockedPath && !isWriteAllowed) {
|
|
1296
|
-
const choice = await promptWriteBlock(ctx, blockedPath);
|
|
1297
|
-
if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
|
|
1298
|
-
} else if (isDeniedByDenyRead || !isReadAllowed) {
|
|
1299
|
-
const choice = await promptReadBlock(
|
|
1300
|
-
ctx,
|
|
1301
|
-
blockedPath,
|
|
1302
|
-
isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
|
|
1303
|
-
);
|
|
1304
|
-
if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
|
|
1305
|
-
} else if (!isWriteAllowed) {
|
|
1306
|
-
const choice = await promptWriteBlock(ctx, blockedPath);
|
|
1307
|
-
if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
|
|
1308
|
-
}
|
|
1309
|
-
} else if (!blockedPath && ctx.hasUI) {
|
|
1384
|
+
if (!blockedPath && ctx.hasUI) {
|
|
1310
1385
|
const landstripErrors = parseLandstripTraps(errorOutput);
|
|
1311
1386
|
if (landstripErrors.length > 0) {
|
|
1312
1387
|
const formatted = formatLandstripTraps(landstripErrors);
|
|
@@ -1361,7 +1436,7 @@ export function createLandstripIntegration(
|
|
|
1361
1436
|
return null;
|
|
1362
1437
|
}
|
|
1363
1438
|
|
|
1364
|
-
if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config)
|
|
1439
|
+
if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config))) {
|
|
1365
1440
|
const choice = await promptWriteBlock(ctx, blockedPath);
|
|
1366
1441
|
if (choice === 'abort') return null;
|
|
1367
1442
|
await applyWriteChoice(choice, blockedPath, ctx.cwd);
|
|
@@ -1677,7 +1752,7 @@ export function createLandstripIntegration(
|
|
|
1677
1752
|
};
|
|
1678
1753
|
}
|
|
1679
1754
|
|
|
1680
|
-
if (shouldPromptForWrite(filePath, getEffectiveAllowWrite(config)
|
|
1755
|
+
if (shouldPromptForWrite(filePath, getEffectiveAllowWrite(config))) {
|
|
1681
1756
|
const choice = await promptWriteBlock(ctx, filePath);
|
|
1682
1757
|
if (choice === 'abort') {
|
|
1683
1758
|
return {
|