pi-landstrip 0.16.4 → 0.16.6
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 +121 -38
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -572,8 +572,9 @@ function notify(ctx: ExtensionContext, message: string, level: NotificationLevel
|
|
|
572
572
|
}
|
|
573
573
|
|
|
574
574
|
function hasTuiStatus(ctx: ExtensionContext): boolean {
|
|
575
|
-
|
|
576
|
-
|
|
575
|
+
if (!ctx.hasUI) return false;
|
|
576
|
+
const mode = 'mode' in ctx ? (ctx as Record<string, unknown>).mode : undefined;
|
|
577
|
+
return mode === undefined || mode === 'tui';
|
|
577
578
|
}
|
|
578
579
|
|
|
579
580
|
function setTuiStatus(ctx: ExtensionContext, key: string, value: string | undefined): void {
|
|
@@ -862,9 +863,9 @@ type LandstripBashTool = ReturnType<typeof createBashToolDefinition>;
|
|
|
862
863
|
/** Options for creating a landstrip sandbox integration. */
|
|
863
864
|
export interface LandstripIntegrationOptions {
|
|
864
865
|
/** Register a sandboxed bash tool when the integration is registered. */
|
|
865
|
-
registerBashTool?: boolean;
|
|
866
|
+
readonly registerBashTool?: boolean;
|
|
866
867
|
/** Working directory used when registering the default bash tool. */
|
|
867
|
-
cwd?: string;
|
|
868
|
+
readonly cwd?: string;
|
|
868
869
|
}
|
|
869
870
|
|
|
870
871
|
/** Landstrip sandbox integration hooks for Pi. */
|
|
@@ -1158,13 +1159,17 @@ export function createLandstripIntegration(
|
|
|
1158
1159
|
server.on('error', reject);
|
|
1159
1160
|
let client: NetSocket | null = null;
|
|
1160
1161
|
server.on('connection', (serverEnd) => {
|
|
1162
|
+
server.removeListener('error', reject);
|
|
1161
1163
|
server.close();
|
|
1162
1164
|
try {
|
|
1163
1165
|
rmSync(sockPath, { force: true });
|
|
1164
1166
|
} catch {
|
|
1165
1167
|
/* ok */
|
|
1166
1168
|
}
|
|
1167
|
-
if (client)
|
|
1169
|
+
if (client) {
|
|
1170
|
+
client.removeListener('error', reject);
|
|
1171
|
+
resolve([client, serverEnd]);
|
|
1172
|
+
}
|
|
1168
1173
|
});
|
|
1169
1174
|
server.listen(sockPath, () => {
|
|
1170
1175
|
client = new NetSocket();
|
|
@@ -1218,7 +1223,7 @@ export function createLandstripIntegration(
|
|
|
1218
1223
|
childEnd.destroy();
|
|
1219
1224
|
|
|
1220
1225
|
function killChild(): void {
|
|
1221
|
-
if (
|
|
1226
|
+
if (child.pid === undefined) return;
|
|
1222
1227
|
try {
|
|
1223
1228
|
process.kill(-child.pid, 'SIGKILL');
|
|
1224
1229
|
} catch {
|
|
@@ -1247,9 +1252,114 @@ export function createLandstripIntegration(
|
|
|
1247
1252
|
callbacks.onStderr?.(data);
|
|
1248
1253
|
onData(data);
|
|
1249
1254
|
});
|
|
1255
|
+
let trapBuffer = '';
|
|
1256
|
+
let queryChain: Promise<void> = Promise.resolve();
|
|
1257
|
+
|
|
1258
|
+
const respondQuery = (queryId: number, action: 'allow' | 'deny'): void => {
|
|
1259
|
+
if (trapSocket.destroyed) return;
|
|
1260
|
+
trapSocket.write(JSON.stringify({ query_id: queryId, action }) + '\n');
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
// Surface a denial through the error-fd accumulator so the post-close
|
|
1264
|
+
// notify and the runBashWithOptionalRetry prompt/retry paths still work.
|
|
1265
|
+
const appendErrorLine = (line: string): void => {
|
|
1266
|
+
const infoLine = line + '\n';
|
|
1267
|
+
errorFdAcc += infoLine;
|
|
1268
|
+
callbacks.onErrorFd?.(Buffer.from(infoLine, 'utf8'));
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
// Answer a landstrip query (state:"query"). The broker suspends the
|
|
1272
|
+
// child's syscall until we respond allow/deny on the trap socket.
|
|
1273
|
+
const handleQuery = (
|
|
1274
|
+
queryId: number,
|
|
1275
|
+
operation: LandstripOperation,
|
|
1276
|
+
rawPath: string,
|
|
1277
|
+
rawLine: string,
|
|
1278
|
+
): void => {
|
|
1279
|
+
const path = normalizeBlockedPath(rawPath, cwd);
|
|
1280
|
+
const config = loadConfig(cwd);
|
|
1281
|
+
const isAllowed = (cfg: SandboxConfig): boolean =>
|
|
1282
|
+
operation === 'read'
|
|
1283
|
+
? !matchesPattern(path, cfg.filesystem.denyRead) &&
|
|
1284
|
+
matchesPattern(path, getEffectiveAllowRead(cfg))
|
|
1285
|
+
: !matchesPattern(path, cfg.filesystem.denyWrite) &&
|
|
1286
|
+
!shouldPromptForWrite(path, getEffectiveAllowWrite(cfg));
|
|
1287
|
+
|
|
1288
|
+
if (isAllowed(config)) {
|
|
1289
|
+
respondQuery(queryId, 'allow');
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
// Without an interactive prompt, deny and let the retry path grant.
|
|
1293
|
+
if (!ctx.hasUI || !callbacks.promptOnBlock) {
|
|
1294
|
+
appendErrorLine(rawLine);
|
|
1295
|
+
respondQuery(queryId, 'deny');
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
// denyWrite is a hard block: never prompt to override it.
|
|
1299
|
+
if (operation === 'write' && matchesPattern(path, config.filesystem.denyWrite)) {
|
|
1300
|
+
respondQuery(queryId, 'deny');
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
// Serialize prompts so concurrent queries never overlap on screen and
|
|
1304
|
+
// a path granted by one prompt auto-allows later queries for it.
|
|
1305
|
+
queryChain = queryChain
|
|
1306
|
+
.then(async () => {
|
|
1307
|
+
const cfg = loadConfig(cwd);
|
|
1308
|
+
if (isAllowed(cfg)) {
|
|
1309
|
+
respondQuery(queryId, 'allow');
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
const choice =
|
|
1313
|
+
operation === 'read'
|
|
1314
|
+
? await promptReadBlock(
|
|
1315
|
+
ctx,
|
|
1316
|
+
path,
|
|
1317
|
+
matchesPattern(path, cfg.filesystem.denyRead)
|
|
1318
|
+
? 'granting allowRead will override it'
|
|
1319
|
+
: undefined,
|
|
1320
|
+
)
|
|
1321
|
+
: await promptWriteBlock(ctx, path);
|
|
1322
|
+
if (choice === 'abort') {
|
|
1323
|
+
respondQuery(queryId, 'deny');
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
if (operation === 'read') await applyReadChoice(choice, path, cwd);
|
|
1327
|
+
else await applyWriteChoice(choice, path, cwd);
|
|
1328
|
+
respondQuery(queryId, 'allow');
|
|
1329
|
+
})
|
|
1330
|
+
.catch(() => respondQuery(queryId, 'deny'));
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1250
1333
|
trapSocket.on('data', (data: Buffer) => {
|
|
1251
|
-
|
|
1252
|
-
|
|
1334
|
+
trapBuffer += data.toString('utf8');
|
|
1335
|
+
let nl = trapBuffer.indexOf('\n');
|
|
1336
|
+
while (nl !== -1) {
|
|
1337
|
+
const line = trapBuffer.slice(0, nl);
|
|
1338
|
+
trapBuffer = trapBuffer.slice(nl + 1);
|
|
1339
|
+
nl = trapBuffer.indexOf('\n');
|
|
1340
|
+
if (line.length === 0) continue;
|
|
1341
|
+
let obj: Record<string, unknown> | null = null;
|
|
1342
|
+
try {
|
|
1343
|
+
const parsed: unknown = JSON.parse(line);
|
|
1344
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
1345
|
+
obj = parsed as Record<string, unknown>;
|
|
1346
|
+
}
|
|
1347
|
+
} catch {
|
|
1348
|
+
obj = null;
|
|
1349
|
+
}
|
|
1350
|
+
if (
|
|
1351
|
+
obj &&
|
|
1352
|
+
obj.state === 'query' &&
|
|
1353
|
+
typeof obj.query_id === 'number' &&
|
|
1354
|
+
(obj.operation === 'read' || obj.operation === 'write') &&
|
|
1355
|
+
typeof obj.path === 'string'
|
|
1356
|
+
) {
|
|
1357
|
+
handleQuery(obj.query_id, obj.operation, obj.path, line);
|
|
1358
|
+
} else {
|
|
1359
|
+
// Informational trap (network denials, etc.): keep for post-close handling.
|
|
1360
|
+
appendErrorLine(line);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1253
1363
|
});
|
|
1254
1364
|
|
|
1255
1365
|
child.on('error', (error) => {
|
|
@@ -1271,39 +1381,12 @@ export function createLandstripIntegration(
|
|
|
1271
1381
|
|
|
1272
1382
|
const errorOutput = errorFdAcc || stderrAcc;
|
|
1273
1383
|
|
|
1384
|
+
// Filesystem denials are now answered live during execution; only
|
|
1385
|
+
// informational traps (network, etc.) remain to surface here.
|
|
1274
1386
|
const blockedPath =
|
|
1275
1387
|
extractBlockedPath(errorOutput, cwd) ??
|
|
1276
1388
|
(errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
|
|
1277
|
-
|
|
1278
|
-
extractBlockedWritePath(errorOutput, cwd) ??
|
|
1279
|
-
(errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
|
|
1280
|
-
if (blockedPath && ctx.hasUI && callbacks.promptOnBlock) {
|
|
1281
|
-
const config = loadConfig(cwd);
|
|
1282
|
-
const isDeniedByDenyRead = matchesPattern(
|
|
1283
|
-
blockedPath,
|
|
1284
|
-
config.filesystem.denyRead,
|
|
1285
|
-
);
|
|
1286
|
-
const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(config));
|
|
1287
|
-
const isWriteAllowed = !shouldPromptForWrite(
|
|
1288
|
-
blockedPath,
|
|
1289
|
-
getEffectiveAllowWrite(config),
|
|
1290
|
-
);
|
|
1291
|
-
|
|
1292
|
-
if (blockedWritePath === blockedPath && !isWriteAllowed) {
|
|
1293
|
-
const choice = await promptWriteBlock(ctx, blockedPath);
|
|
1294
|
-
if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
|
|
1295
|
-
} else if (isDeniedByDenyRead || !isReadAllowed) {
|
|
1296
|
-
const choice = await promptReadBlock(
|
|
1297
|
-
ctx,
|
|
1298
|
-
blockedPath,
|
|
1299
|
-
isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
|
|
1300
|
-
);
|
|
1301
|
-
if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
|
|
1302
|
-
} else if (!isWriteAllowed) {
|
|
1303
|
-
const choice = await promptWriteBlock(ctx, blockedPath);
|
|
1304
|
-
if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
|
|
1305
|
-
}
|
|
1306
|
-
} else if (!blockedPath && ctx.hasUI) {
|
|
1389
|
+
if (!blockedPath && ctx.hasUI) {
|
|
1307
1390
|
const landstripErrors = parseLandstripTraps(errorOutput);
|
|
1308
1391
|
if (landstripErrors.length > 0) {
|
|
1309
1392
|
const formatted = formatLandstripTraps(landstripErrors);
|