pi-landstrip 0.11.0 → 0.11.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 +114 -60
- package/package.json +2 -2
package/index.ts
CHANGED
|
@@ -72,15 +72,36 @@ interface LandstripPolicy {
|
|
|
72
72
|
filesystem: SandboxFilesystemConfig;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
type LandstripErrorReason = 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
|
|
76
|
+
type LandstripOperation = 'read' | 'write';
|
|
77
|
+
type LandstripErrorType = 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
|
|
78
|
+
|
|
75
79
|
interface LandstripErrorResponse {
|
|
76
|
-
reason:
|
|
80
|
+
reason: LandstripErrorReason;
|
|
77
81
|
file?: string;
|
|
82
|
+
operation?: LandstripOperation;
|
|
78
83
|
program?: string;
|
|
79
|
-
type?:
|
|
80
|
-
source
|
|
84
|
+
type?: LandstripErrorType;
|
|
85
|
+
source?: string;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
const LANDSTRIP_VERSION = [0, 11,
|
|
88
|
+
const LANDSTRIP_VERSION = [0, 11, 6] as const;
|
|
89
|
+
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
90
|
+
const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorReason>([
|
|
91
|
+
'Other',
|
|
92
|
+
'AccessDenied',
|
|
93
|
+
'LaunchFailed',
|
|
94
|
+
'SetupFailed',
|
|
95
|
+
'Usage',
|
|
96
|
+
]);
|
|
97
|
+
const LANDSTRIP_OPERATIONS = new Set<LandstripOperation>(['read', 'write']);
|
|
98
|
+
const LANDSTRIP_ERROR_TYPES = new Set<LandstripErrorType>([
|
|
99
|
+
'filesystem',
|
|
100
|
+
'network',
|
|
101
|
+
'platform',
|
|
102
|
+
'launch',
|
|
103
|
+
'encoding',
|
|
104
|
+
]);
|
|
84
105
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
85
106
|
|
|
86
107
|
const DEFAULT_CONFIG: SandboxConfig = {
|
|
@@ -226,9 +247,6 @@ function addReadPathToConfig(configPath: string, pathToAdd: string): void {
|
|
|
226
247
|
config.filesystem = {
|
|
227
248
|
...config.filesystem,
|
|
228
249
|
allowRead: [...existing, pathToAdd],
|
|
229
|
-
denyRead: config.filesystem?.denyRead ?? [],
|
|
230
|
-
allowWrite: config.filesystem?.allowWrite ?? [],
|
|
231
|
-
denyWrite: config.filesystem?.denyWrite ?? [],
|
|
232
250
|
} as SandboxFilesystemConfig;
|
|
233
251
|
writeConfigFile(configPath, config);
|
|
234
252
|
}
|
|
@@ -241,9 +259,6 @@ function addWritePathToConfig(configPath: string, pathToAdd: string): void {
|
|
|
241
259
|
config.filesystem = {
|
|
242
260
|
...config.filesystem,
|
|
243
261
|
allowWrite: [...existing, pathToAdd],
|
|
244
|
-
denyRead: config.filesystem?.denyRead ?? [],
|
|
245
|
-
allowRead: config.filesystem?.allowRead ?? [],
|
|
246
|
-
denyWrite: config.filesystem?.denyWrite ?? [],
|
|
247
262
|
} as SandboxFilesystemConfig;
|
|
248
263
|
writeConfigFile(configPath, config);
|
|
249
264
|
}
|
|
@@ -337,19 +352,46 @@ function normalizeBlockedPath(path: string, cwd: string): string {
|
|
|
337
352
|
return canonicalizePath(isAbsolute(path) ? path : join(cwd, path));
|
|
338
353
|
}
|
|
339
354
|
|
|
355
|
+
function isPathLike(value: string): boolean {
|
|
356
|
+
const trimmed = value.trim();
|
|
357
|
+
return (
|
|
358
|
+
trimmed === '~' ||
|
|
359
|
+
trimmed.startsWith('/') ||
|
|
360
|
+
trimmed.startsWith('~/') ||
|
|
361
|
+
trimmed.startsWith('./') ||
|
|
362
|
+
trimmed.startsWith('../') ||
|
|
363
|
+
trimmed.startsWith('.') ||
|
|
364
|
+
trimmed.includes('/')
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function normalizePathMatch(value: string, cwd: string): string | null {
|
|
369
|
+
return isPathLike(value) ? normalizeBlockedPath(value, cwd) : null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isFilesystemAccessDenied(error: LandstripErrorResponse): boolean {
|
|
373
|
+
return error.reason === 'AccessDenied' && error.type === 'filesystem';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isLandstripErrorReason(value: string): value is LandstripErrorReason {
|
|
377
|
+
return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorReason);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isLandstripOperation(value: string): value is LandstripOperation {
|
|
381
|
+
return LANDSTRIP_OPERATIONS.has(value as LandstripOperation);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isLandstripErrorType(value: string): value is LandstripErrorType {
|
|
385
|
+
return LANDSTRIP_ERROR_TYPES.has(value as LandstripErrorType);
|
|
386
|
+
}
|
|
387
|
+
|
|
340
388
|
function extractCandidatePaths(command: string): string[] {
|
|
341
389
|
const paths: string[] = [];
|
|
342
390
|
// Split on whitespace, preserving quoted strings minimally
|
|
343
391
|
const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/g) ?? [];
|
|
344
392
|
for (const token of tokens) {
|
|
345
393
|
const clean = token.replace(/^["']|["']$/g, '').replace(/[,;]$/, '');
|
|
346
|
-
if (
|
|
347
|
-
clean.startsWith('/') ||
|
|
348
|
-
clean.startsWith('~/') ||
|
|
349
|
-
clean === '~' ||
|
|
350
|
-
clean.startsWith('./') ||
|
|
351
|
-
clean.startsWith('../')
|
|
352
|
-
) {
|
|
394
|
+
if (isPathLike(clean)) {
|
|
353
395
|
paths.push(clean);
|
|
354
396
|
}
|
|
355
397
|
}
|
|
@@ -357,36 +399,17 @@ function extractCandidatePaths(command: string): string[] {
|
|
|
357
399
|
}
|
|
358
400
|
|
|
359
401
|
function extractBlockedPath(output: string, cwd: string, command?: string): string | null {
|
|
360
|
-
|
|
361
|
-
let match = output.match(
|
|
362
|
-
/(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
|
|
363
|
-
);
|
|
364
|
-
if (match) return normalizeBlockedPath(match[1], cwd);
|
|
365
|
-
|
|
366
|
-
// ls/cat/cp: cannot open/access/stat '/path': Permission denied
|
|
367
|
-
match = output.match(
|
|
368
|
-
/^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
|
|
369
|
-
);
|
|
370
|
-
if (match) return normalizeBlockedPath(match[1], cwd);
|
|
371
|
-
|
|
372
|
-
// Generic: cmd: /absolute/path: Permission denied or Operation not permitted
|
|
373
|
-
match = output.match(
|
|
374
|
-
/^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
|
|
375
|
-
);
|
|
376
|
-
if (match) return normalizeBlockedPath(match[1], cwd);
|
|
377
|
-
|
|
378
|
-
// Landstrip structured error format with file field
|
|
379
|
-
const landstripErrors = parseLandstripErrors(output);
|
|
402
|
+
const landstripErrors = parseLandstripErrors(output).filter(isFilesystemAccessDenied);
|
|
380
403
|
for (const error of landstripErrors) {
|
|
381
404
|
if (error.file) return normalizeBlockedPath(error.file, cwd);
|
|
382
405
|
}
|
|
383
406
|
|
|
384
407
|
// If landstrip reported an error but without a file field, try to
|
|
385
|
-
// extract the blocked path from the command itself
|
|
408
|
+
// extract the blocked path from the command itself.
|
|
386
409
|
if (landstripErrors.length > 0 && command) {
|
|
387
410
|
const config = loadConfig(cwd);
|
|
388
411
|
for (const candidate of extractCandidatePaths(command)) {
|
|
389
|
-
const resolved =
|
|
412
|
+
const resolved = normalizeBlockedPath(candidate, cwd);
|
|
390
413
|
if (
|
|
391
414
|
matchesPattern(resolved, config.filesystem.denyRead) ||
|
|
392
415
|
!matchesPattern(resolved, config.filesystem.allowRead)
|
|
@@ -396,11 +419,35 @@ function extractBlockedPath(output: string, cwd: string, command?: string): stri
|
|
|
396
419
|
}
|
|
397
420
|
}
|
|
398
421
|
|
|
422
|
+
// bash/sh: line X: /path: Permission denied
|
|
423
|
+
let match = output.match(
|
|
424
|
+
/(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
|
|
425
|
+
);
|
|
426
|
+
if (match) return normalizePathMatch(match[1], cwd);
|
|
427
|
+
|
|
428
|
+
// ls/cat/cp: cannot open/access/stat '/path': Permission denied
|
|
429
|
+
match = output.match(
|
|
430
|
+
/^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
|
|
431
|
+
);
|
|
432
|
+
if (match) return normalizePathMatch(match[1], cwd);
|
|
433
|
+
|
|
434
|
+
// Generic: cmd: /absolute/path: Permission denied or Operation not permitted
|
|
435
|
+
match = output.match(
|
|
436
|
+
/^[a-zA-Z0-9_-]+: (\/[^:\n]+): (?:Operation not permitted|Permission denied)$/m,
|
|
437
|
+
);
|
|
438
|
+
if (match) return normalizeBlockedPath(match[1], cwd);
|
|
439
|
+
|
|
399
440
|
return null;
|
|
400
441
|
}
|
|
401
442
|
|
|
402
|
-
function extractBlockedWritePath(output: string, cwd: string
|
|
403
|
-
|
|
443
|
+
function extractBlockedWritePath(output: string, cwd: string): string | null {
|
|
444
|
+
for (const error of parseLandstripErrors(output).filter(isFilesystemAccessDenied)) {
|
|
445
|
+
if (error.file && error.operation === 'write') {
|
|
446
|
+
return normalizeBlockedPath(error.file, cwd);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return null;
|
|
404
451
|
}
|
|
405
452
|
|
|
406
453
|
function parseLandstripErrors(output: string): LandstripErrorResponse[] {
|
|
@@ -419,22 +466,21 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
|
|
|
419
466
|
|
|
420
467
|
if (
|
|
421
468
|
fields.reason &&
|
|
422
|
-
|
|
423
|
-
fields.source
|
|
469
|
+
isLandstripErrorReason(fields.reason) &&
|
|
470
|
+
(fields.source || fields.reason === 'AccessDenied')
|
|
424
471
|
) {
|
|
425
|
-
const error: LandstripErrorResponse = {
|
|
426
|
-
reason: fields.reason as LandstripErrorResponse['reason'],
|
|
427
|
-
source: fields.source,
|
|
428
|
-
};
|
|
472
|
+
const error: LandstripErrorResponse = { reason: fields.reason };
|
|
429
473
|
|
|
474
|
+
if (fields.source) error.source = fields.source;
|
|
430
475
|
if (fields.file) error.file = fields.file;
|
|
431
476
|
if (fields.program) error.program = fields.program;
|
|
432
477
|
|
|
433
|
-
if (
|
|
434
|
-
fields.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
478
|
+
if (fields.operation && isLandstripOperation(fields.operation)) {
|
|
479
|
+
error.operation = fields.operation;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (fields.type && isLandstripErrorType(fields.type)) {
|
|
483
|
+
error.type = fields.type;
|
|
438
484
|
}
|
|
439
485
|
|
|
440
486
|
errors.push(error);
|
|
@@ -458,7 +504,12 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
|
|
|
458
504
|
if (err.type) {
|
|
459
505
|
parts.push(`:${err.type}`);
|
|
460
506
|
}
|
|
461
|
-
|
|
507
|
+
if (err.operation) {
|
|
508
|
+
parts.push(`:${err.operation}`);
|
|
509
|
+
}
|
|
510
|
+
if (err.source) {
|
|
511
|
+
parts.push(`: ${err.source}`);
|
|
512
|
+
}
|
|
462
513
|
|
|
463
514
|
return parts.join('');
|
|
464
515
|
})
|
|
@@ -1080,6 +1131,7 @@ export function createLandstripIntegration(
|
|
|
1080
1131
|
}
|
|
1081
1132
|
|
|
1082
1133
|
const blockedPath = extractBlockedPath(stderrAcc, cwd, command);
|
|
1134
|
+
const blockedWritePath = extractBlockedWritePath(stderrAcc, cwd);
|
|
1083
1135
|
if (blockedPath && ctx.hasUI) {
|
|
1084
1136
|
const config = loadConfig(cwd);
|
|
1085
1137
|
const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
|
|
@@ -1090,7 +1142,10 @@ export function createLandstripIntegration(
|
|
|
1090
1142
|
matchesPattern,
|
|
1091
1143
|
);
|
|
1092
1144
|
|
|
1093
|
-
if (
|
|
1145
|
+
if (blockedWritePath === blockedPath && !isWriteAllowed) {
|
|
1146
|
+
const choice = await promptWriteBlock(ctx, blockedPath);
|
|
1147
|
+
if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
|
|
1148
|
+
} else if (isDeniedByDenyRead || !isReadAllowed) {
|
|
1094
1149
|
const choice = await promptReadBlock(
|
|
1095
1150
|
ctx,
|
|
1096
1151
|
blockedPath,
|
|
@@ -1142,16 +1197,12 @@ export function createLandstripIntegration(
|
|
|
1142
1197
|
}
|
|
1143
1198
|
throw error;
|
|
1144
1199
|
}
|
|
1145
|
-
const outputText = result.content
|
|
1146
|
-
.filter((content) => content.type === 'text')
|
|
1147
|
-
.map((content) => content.text)
|
|
1148
|
-
.join('\n');
|
|
1149
1200
|
const landstripErrors = parseLandstripErrors(landstripStderr);
|
|
1150
1201
|
if (landstripErrors.length > 0) {
|
|
1151
1202
|
const message = formatLandstripErrors(landstripErrors);
|
|
1152
1203
|
result.content.unshift({ type: 'text', text: `\n${message}\n` });
|
|
1153
1204
|
}
|
|
1154
|
-
const blockedPath = extractBlockedWritePath(
|
|
1205
|
+
const blockedPath = extractBlockedWritePath(landstripStderr, ctx.cwd);
|
|
1155
1206
|
|
|
1156
1207
|
if (!blockedPath || !ctx.hasUI) return result;
|
|
1157
1208
|
|
|
@@ -1251,7 +1302,10 @@ export function createLandstripIntegration(
|
|
|
1251
1302
|
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
1252
1303
|
sandboxEnabled = false;
|
|
1253
1304
|
sandboxReady = false;
|
|
1254
|
-
ctx.ui.notify(
|
|
1305
|
+
ctx.ui.notify(
|
|
1306
|
+
`landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
|
|
1307
|
+
'error',
|
|
1308
|
+
);
|
|
1255
1309
|
return false;
|
|
1256
1310
|
}
|
|
1257
1311
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-landstrip",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
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
|
-
"@jarkkojs/landstrip": "^0.11.
|
|
34
|
+
"@jarkkojs/landstrip": "^0.11.6"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@earendil-works/pi-coding-agent": "^0.78.0",
|