pi-landstrip 0.4.0 → 0.5.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.
Files changed (2) hide show
  1. package/index.ts +386 -135
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  isToolCallEventType,
38
38
  SettingsManager,
39
39
  } from '@earendil-works/pi-coding-agent';
40
- import { Key, matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
40
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
41
41
 
42
42
  interface SandboxFilesystemConfig {
43
43
  denyRead: string[];
@@ -80,7 +80,7 @@ interface LandstripErrorResponse {
80
80
  message: string;
81
81
  }
82
82
 
83
- const LANDSTRIP_VERSION = [0, 10, 1] as const;
83
+ const LANDSTRIP_VERSION = [0, 10, 2] as const;
84
84
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
85
85
 
86
86
  const DEFAULT_CONFIG: SandboxConfig = {
@@ -109,8 +109,8 @@ const DEFAULT_CONFIG: SandboxConfig = {
109
109
  },
110
110
  filesystem: {
111
111
  denyRead: ['/Users', '/home'],
112
- allowRead: ['.', '~/.config', '~/.gitconfig', '~/.local', '~/.cargo'],
113
- allowWrite: ['.', '/tmp'],
112
+ allowRead: ['.', '~/.config', '~/.gitconfig', '~/.local', '~/.cargo', '/dev/null'],
113
+ allowWrite: ['.', '/tmp', '/dev/null'],
114
114
  denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
115
115
  },
116
116
  };
@@ -337,12 +337,30 @@ function normalizeBlockedPath(path: string, cwd: string): string {
337
337
  return canonicalizePath(isAbsolute(path) ? path : join(cwd, path));
338
338
  }
339
339
 
340
- function extractBlockedWritePath(output: string, cwd: string): string | null {
341
- const match = output.match(
340
+ function extractBlockedPath(output: string, cwd: string): string | null {
341
+ // bash/sh: line X: /path: Permission denied
342
+ let match = output.match(
342
343
  /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
343
344
  );
345
+ if (match) return normalizeBlockedPath(match[1], cwd);
346
+
347
+ // ls/cat/cp: cannot open/access/stat '/path': Permission denied
348
+ match = output.match(
349
+ /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
350
+ );
351
+ if (match) return normalizeBlockedPath(match[1], cwd);
344
352
 
345
- return match ? normalizeBlockedPath(match[1], cwd) : null;
353
+ // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
354
+ match = output.match(
355
+ /^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
356
+ );
357
+ if (match) return normalizeBlockedPath(match[1], cwd);
358
+
359
+ return null;
360
+ }
361
+
362
+ function extractBlockedWritePath(output: string, cwd: string): string | null {
363
+ return extractBlockedPath(output, cwd);
346
364
  }
347
365
 
348
366
  function parseLandstripErrors(output: string): LandstripErrorResponse[] {
@@ -414,99 +432,168 @@ async function showPermissionPrompt(
414
432
  ): Promise<PermissionChoice> {
415
433
  if (!ctx.hasUI) return 'abort';
416
434
 
417
- const result = await ctx.ui.custom<PermissionChoice>((tui, theme, _kb, done) => {
418
- let selectedIndex = 0;
419
- let pendingAction: PermissionChoice | null = null;
435
+ const result = await ctx.ui.custom<PermissionChoice>(
436
+ (tui, theme, _kb, done) => {
437
+ let selectedIndex = 0;
438
+ let pendingAction: PermissionChoice | null = null;
420
439
 
421
- function resolveChoice(action: PermissionChoice): void {
422
- done(action);
423
- }
440
+ function resolveChoice(action: PermissionChoice): void {
441
+ done(action);
442
+ }
424
443
 
425
- return {
426
- render(width: number): string[] {
427
- const lines: string[] = [];
428
- lines.push(truncateToWidth(theme.fg('warning', title), width));
429
- lines.push('');
430
-
431
- for (let i = 0; i < options.length; i++) {
432
- const option = options[i];
433
- const isSelected = i === selectedIndex;
434
- const isPending = pendingAction === option.action;
435
- const prefix = isSelected ? ' -> ' : ' ';
436
- const keyHint = theme.fg('accent', `[${option.key}]`);
437
- let label = option.label;
438
-
439
- if (option.hint) label += ` ${theme.fg('dim', option.hint)}`;
440
- if (isPending) label += ` ${theme.fg('warning', '-> press Enter to confirm')}`;
441
-
442
- lines.push(truncateToWidth(`${prefix}${keyHint} ${label}`, width));
443
- }
444
+ return {
445
+ render(width: number): string[] {
446
+ const innerW = width - 4;
447
+ const lines: string[] = [];
448
+ const border = theme.fg('border', '│');
449
+ const dim = (s: string) => theme.fg('dim', s);
450
+ const borderFg = (s: string) => theme.fg('border', s);
451
+
452
+ // Top border with title
453
+ const label = ' Sandbox ';
454
+ const topLeft = borderFg('╭─');
455
+ const topRight = borderFg('─╮');
456
+ const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(label))));
457
+ lines.push(`${topLeft}${theme.fg('accent', label)}${topFill}${topRight}`);
458
+
459
+ // Blank spacing
460
+ lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
461
+
462
+ // Title line
463
+ const titleText = truncateToWidth(theme.fg('warning', title), innerW);
464
+ const titlePad = Math.max(0, innerW - visibleWidth(titleText));
465
+ lines.push(`${border} ${titleText}${' '.repeat(titlePad)} ${border}`);
466
+
467
+ lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
468
+
469
+ // Options
470
+ for (let i = 0; i < options.length; i++) {
471
+ const option = options[i];
472
+ const isSelected = i === selectedIndex;
473
+ const isPending = pendingAction === option.action;
474
+
475
+ // Section divider before the permanent options (index 2 and 3)
476
+ if (i === 2) {
477
+ lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
478
+ const secLabel = ' Permanent ';
479
+ const secDash = '─'.repeat(Math.max(0, innerW - visibleWidth(secLabel)));
480
+ lines.push(`${border} ${dim(secDash + secLabel)} ${border}`);
481
+ lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
482
+ }
444
483
 
445
- lines.push('');
446
- lines.push(
447
- truncateToWidth(
448
- theme.fg(
449
- 'dim',
450
- pendingAction
451
- ? 'up/down navigate enter confirm esc cancel'
452
- : 'up/down navigate enter select esc cancel',
453
- ),
454
- width,
455
- ),
456
- );
484
+ // Key badge
485
+ const keyBadge = isSelected
486
+ ? theme.fg('accent', `[${option.key}]`)
487
+ : dim(` ${option.key} `);
488
+
489
+ // Selection indicator
490
+ let cursor: string;
491
+ if (isSelected && isPending) {
492
+ cursor = theme.fg('warning', '▶');
493
+ } else if (isSelected) {
494
+ cursor = theme.fg('accent', '▶');
495
+ } else {
496
+ cursor = ' ';
497
+ }
457
498
 
458
- return lines;
459
- },
499
+ // Label
500
+ let label: string;
501
+ if (isPending) {
502
+ label = theme.fg('warning', option.label + ' — press Enter to confirm');
503
+ } else if (isSelected) {
504
+ label = theme.fg('text', option.label);
505
+ } else {
506
+ label = dim(option.label);
507
+ }
460
508
 
461
- handleInput(data: string): void {
462
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
463
- resolveChoice('abort');
464
- return;
465
- }
509
+ // Hint
510
+ let hint = '';
511
+ if (option.hint && !isPending) {
512
+ hint = ' ' + dim(option.hint);
513
+ }
466
514
 
467
- if (matchesKey(data, Key.enter)) {
468
- resolveChoice(pendingAction ?? options[selectedIndex]?.action ?? 'abort');
469
- return;
470
- }
515
+ const fullLine = ` ${cursor} ${keyBadge} ${label}${hint}`;
516
+ const line = truncateToWidth(fullLine, innerW);
517
+ const pad = Math.max(0, innerW - visibleWidth(line));
518
+ lines.push(`${border} ${line}${' '.repeat(pad)} ${border}`);
519
+ }
471
520
 
472
- if (matchesKey(data, Key.up)) {
473
- selectedIndex = Math.max(0, selectedIndex - 1);
474
- pendingAction = null;
475
- tui.requestRender();
476
- return;
477
- }
521
+ // Footer
522
+ lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
523
+ const footerText = pendingAction
524
+ ? '↑↓ navigate enter confirm esc cancel'
525
+ : '↑↓ navigate enter select esc dismiss';
526
+ const footerLine = dim(footerText);
527
+ const footerPad = Math.max(0, innerW - visibleWidth(footerLine));
528
+ lines.push(`${border} ${footerLine}${' '.repeat(footerPad)} ${border}`);
529
+
530
+ // Bottom border
531
+ const botLeft = borderFg('╰');
532
+ const botRight = borderFg('╯');
533
+ const botFill = borderFg('─'.repeat(width - 2));
534
+ lines.push(`${botLeft}${botFill}${botRight}`);
535
+
536
+ return lines;
537
+ },
538
+
539
+ handleInput(data: string): void {
540
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
541
+ resolveChoice('abort');
542
+ return;
543
+ }
478
544
 
479
- if (matchesKey(data, Key.down)) {
480
- selectedIndex = Math.min(options.length - 1, selectedIndex + 1);
481
- pendingAction = null;
482
- tui.requestRender();
483
- return;
484
- }
545
+ if (matchesKey(data, Key.enter)) {
546
+ resolveChoice(pendingAction ?? options[selectedIndex]?.action ?? 'abort');
547
+ return;
548
+ }
485
549
 
486
- for (let i = 0; i < options.length; i++) {
487
- const option = options[i];
550
+ if (matchesKey(data, Key.up)) {
551
+ selectedIndex = Math.max(0, selectedIndex - 1);
552
+ pendingAction = null;
553
+ tui.requestRender();
554
+ return;
555
+ }
488
556
 
489
- if (data === option.key) {
490
- resolveChoice(option.action);
557
+ if (matchesKey(data, Key.down)) {
558
+ selectedIndex = Math.min(options.length - 1, selectedIndex + 1);
559
+ pendingAction = null;
560
+ tui.requestRender();
491
561
  return;
492
562
  }
493
563
 
494
- if (data.toLowerCase() === option.key.toLowerCase()) {
495
- if (option.confirm) {
496
- pendingAction = option.action;
497
- selectedIndex = i;
498
- } else {
564
+ for (let i = 0; i < options.length; i++) {
565
+ const option = options[i];
566
+
567
+ if (data === option.key) {
499
568
  resolveChoice(option.action);
569
+ return;
570
+ }
571
+
572
+ if (data.toLowerCase() === option.key.toLowerCase()) {
573
+ if (option.confirm) {
574
+ pendingAction = option.action;
575
+ selectedIndex = i;
576
+ } else {
577
+ resolveChoice(option.action);
578
+ }
579
+ tui.requestRender();
580
+ return;
500
581
  }
501
- tui.requestRender();
502
- return;
503
582
  }
504
- }
505
- },
583
+ },
506
584
 
507
- invalidate(): void {},
508
- };
509
- });
585
+ invalidate(): void {},
586
+ };
587
+ },
588
+ {
589
+ overlay: true,
590
+ overlayOptions: {
591
+ anchor: 'center',
592
+ width: 72,
593
+ margin: 2,
594
+ },
595
+ },
596
+ );
510
597
 
511
598
  return result ?? 'abort';
512
599
  }
@@ -519,12 +606,15 @@ function promptDomainBlock(ctx: ExtensionContext, domain: string): Promise<Permi
519
606
  );
520
607
  }
521
608
 
522
- function promptReadBlock(ctx: ExtensionContext, filePath: string): Promise<PermissionChoice> {
523
- return showPermissionPrompt(
524
- ctx,
525
- `Read blocked: "${filePath}" is not in allowRead`,
526
- PERMISSION_OPTIONS,
527
- );
609
+ function promptReadBlock(
610
+ ctx: ExtensionContext,
611
+ filePath: string,
612
+ reason?: string,
613
+ ): Promise<PermissionChoice> {
614
+ const title = reason
615
+ ? `Read blocked: "${filePath}" is in denyRead (${reason})`
616
+ : `Read blocked: "${filePath}" is not in allowRead`;
617
+ return showPermissionPrompt(ctx, title, PERMISSION_OPTIONS);
528
618
  }
529
619
 
530
620
  function promptWriteBlock(ctx: ExtensionContext, filePath: string): Promise<PermissionChoice> {
@@ -924,8 +1014,11 @@ export function createLandstripIntegration(
924
1014
  }
925
1015
 
926
1016
  signal?.addEventListener('abort', onAbort, { once: true });
1017
+ let stderrAcc = '';
1018
+
927
1019
  child.stdout?.on('data', onData);
928
1020
  child.stderr?.on('data', (data: Buffer) => {
1021
+ stderrAcc += data.toString('utf8');
929
1022
  onStderr(data);
930
1023
  onData(data);
931
1024
  });
@@ -935,15 +1028,42 @@ export function createLandstripIntegration(
935
1028
  reject(error);
936
1029
  });
937
1030
 
938
- child.on('close', (code) => {
1031
+ child.on('close', async (code) => {
939
1032
  cleanup();
940
1033
  if (signal?.aborted) {
941
1034
  reject(new Error('aborted'));
942
- } else if (timedOut) {
1035
+ return;
1036
+ }
1037
+ if (timedOut) {
943
1038
  reject(new Error(`timeout:${timeout}`));
944
- } else {
945
- resolvePromise({ exitCode: code });
1039
+ return;
946
1040
  }
1041
+
1042
+ const blockedPath = extractBlockedPath(stderrAcc, cwd);
1043
+ if (blockedPath && ctx.hasUI) {
1044
+ const config = loadConfig(cwd);
1045
+ const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
1046
+ const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(cwd));
1047
+ const isWriteAllowed = !shouldPromptForWrite(
1048
+ blockedPath,
1049
+ getEffectiveAllowWrite(cwd),
1050
+ matchesPattern,
1051
+ );
1052
+
1053
+ if (isDeniedByDenyRead || !isReadAllowed) {
1054
+ const choice = await promptReadBlock(
1055
+ ctx,
1056
+ blockedPath,
1057
+ isDeniedByDenyRead ? 'denyRead overrides allowRead' : undefined,
1058
+ );
1059
+ if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1060
+ } else if (!isWriteAllowed) {
1061
+ const choice = await promptWriteBlock(ctx, blockedPath);
1062
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1063
+ }
1064
+ }
1065
+
1066
+ resolvePromise({ exitCode: code });
947
1067
  });
948
1068
  });
949
1069
  },
@@ -1037,18 +1157,28 @@ export function createLandstripIntegration(
1037
1157
  }
1038
1158
 
1039
1159
  function enableStatus(ctx: ExtensionContext, config: SandboxConfig): void {
1040
- const networkLabel = config.network.allowNetwork
1041
- ? 'unrestricted'
1042
- : allowsAllDomains(config.network.allowedDomains)
1043
- ? 'all domains'
1044
- : `${config.network.allowedDomains.length} domains`;
1045
- ctx.ui.setStatus(
1046
- 'sandbox',
1047
- ctx.ui.theme.fg(
1048
- 'accent',
1049
- `Sandbox: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
1050
- ),
1051
- );
1160
+ const theme = ctx.ui.theme;
1161
+ const dot = theme.fg('success', '●');
1162
+ const label = theme.fg('text', 'Sandbox');
1163
+
1164
+ let networkLabel: string;
1165
+ let networkColor: 'warning' | 'accent';
1166
+ if (config.network.allowNetwork) {
1167
+ networkLabel = 'unrestricted';
1168
+ networkColor = 'warning';
1169
+ } else if (allowsAllDomains(config.network.allowedDomains)) {
1170
+ networkLabel = 'any domain';
1171
+ networkColor = 'warning';
1172
+ } else {
1173
+ networkLabel = `${config.network.allowedDomains.length} domains`;
1174
+ networkColor = 'accent';
1175
+ }
1176
+
1177
+ const sep = theme.fg('dim', '·');
1178
+ const net = theme.fg(networkColor, networkLabel);
1179
+ const write = theme.fg('accent', `${config.filesystem.allowWrite.length} write paths`);
1180
+
1181
+ ctx.ui.setStatus('sandbox', `${dot} ${label} ${sep} ${net} ${sep} ${write}`);
1052
1182
  }
1053
1183
 
1054
1184
  function enableSandbox(ctx: ExtensionContext): boolean {
@@ -1075,7 +1205,7 @@ export function createLandstripIntegration(
1075
1205
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
1076
1206
  sandboxEnabled = false;
1077
1207
  sandboxReady = false;
1078
- ctx.ui.notify(`landstrip 0.10.1 or newer is required; found: ${version}`, 'error');
1208
+ ctx.ui.notify(`landstrip 0.10.2 or newer is required; found: ${version}`, 'error');
1079
1209
  return false;
1080
1210
  }
1081
1211
 
@@ -1241,7 +1371,7 @@ export function createLandstripIntegration(
1241
1371
 
1242
1372
  sandboxEnabled = false;
1243
1373
  sandboxReady = false;
1244
- ctx.ui.setStatus('sandbox', '');
1374
+ ctx.ui.setStatus('sandbox', undefined);
1245
1375
  ctx.ui.notify('Sandbox disabled', 'info');
1246
1376
  },
1247
1377
  });
@@ -1256,34 +1386,155 @@ export function createLandstripIntegration(
1256
1386
 
1257
1387
  const config = loadConfig(ctx.cwd);
1258
1388
  const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
1259
- const lines = [
1260
- 'Sandbox Configuration',
1261
- ` Project config: ${projectPath}`,
1262
- ` Global config: ${globalPath}`,
1263
- ` landstrip: ${binaryPath()}`,
1264
- '',
1265
- `Network (bash ${config.network.allowNetwork ? 'unrestricted' : 'through HTTP proxy'}):`,
1266
- ` Allow network: ${config.network.allowNetwork}`,
1267
- ` Allowed domains: ${config.network.allowedDomains.join(', ') || '(none)'}`,
1268
- ` Denied domains: ${config.network.deniedDomains.join(', ') || '(none)'}`,
1269
- ...(sessionAllowedDomains.length > 0
1270
- ? [` Session allowed: ${sessionAllowedDomains.join(', ')}`]
1271
- : []),
1272
- '',
1273
- 'Filesystem (bash + read/write/edit tools):',
1274
- ` Deny Read: ${config.filesystem.denyRead.join(', ') || '(none)'}`,
1275
- ` Allow Read: ${config.filesystem.allowRead.join(', ') || '(none)'}`,
1276
- ` Allow Write: ${config.filesystem.allowWrite.join(', ') || '(none)'}`,
1277
- ` Deny Write: ${config.filesystem.denyWrite.join(', ') || '(none)'}`,
1278
- ...(sessionAllowedReadPaths.length > 0
1279
- ? [` Session read: ${sessionAllowedReadPaths.join(', ')}`]
1280
- : []),
1281
- ...(sessionAllowedWritePaths.length > 0
1282
- ? [` Session write: ${sessionAllowedWritePaths.join(', ')}`]
1283
- : []),
1284
- ];
1285
-
1286
- ctx.ui.notify(lines.join('\n'), 'info');
1389
+
1390
+ await ctx.ui.custom(
1391
+ (tui, theme, _kb, done) => {
1392
+ const dim = (s: string) => theme.fg('dim', s);
1393
+ const muted = (s: string) => theme.fg('muted', s);
1394
+ const accent = (s: string) => theme.fg('accent', s);
1395
+ const text = (s: string) => theme.fg('text', s);
1396
+ const borderFg = (s: string) => theme.fg('border', s);
1397
+
1398
+ function boolVal(v: boolean): string {
1399
+ return v ? theme.fg('warning', 'yes') : theme.fg('success', 'no');
1400
+ }
1401
+
1402
+ function makeRow(content: string, innerW: number, border: string): string {
1403
+ const line = truncateToWidth(content, innerW);
1404
+ const pad = Math.max(0, innerW - visibleWidth(line));
1405
+ return `${border} ${line}${' '.repeat(pad)} ${border}`;
1406
+ }
1407
+
1408
+ return {
1409
+ render(width: number): string[] {
1410
+ const innerW = width - 4;
1411
+ const border = borderFg('│');
1412
+ const row = (c: string) => makeRow(c, innerW, border);
1413
+ const lines: string[] = [];
1414
+
1415
+ // Top border
1416
+ const title = accent(' Sandbox Configuration ');
1417
+ const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
1418
+ lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
1419
+
1420
+ // Status
1421
+ const statusDot = theme.fg('success', '●');
1422
+ const pathSnippet = text(truncateToWidth(binaryPath(), Math.max(20, innerW - 27)));
1423
+ lines.push(
1424
+ row(
1425
+ ` ${statusDot} ${text('Active')} ${dim('·')} ${muted('landstrip:')} ${pathSnippet}`,
1426
+ ),
1427
+ );
1428
+
1429
+ // Config files
1430
+ lines.push(row(` ${dim('Config files:')}`));
1431
+ lines.push(row(` ${dim('project')} ${text(projectPath)}`));
1432
+ lines.push(row(` ${dim('global')} ${text(globalPath)}`));
1433
+
1434
+ // Network section
1435
+ lines.push(row(''));
1436
+ lines.push(row(`${'─'.repeat(innerW)}`));
1437
+ const netMode = config.network.allowNetwork ? ' (unrestricted)' : ' (proxied)';
1438
+ lines.push(row(` ${accent('Network')}${dim(netMode)}`));
1439
+ lines.push(
1440
+ row(
1441
+ ` ${dim('•')} ${muted('Allow network:')} ${boolVal(config.network.allowNetwork)}`,
1442
+ ),
1443
+ );
1444
+ const domainsStr = config.network.allowedDomains.join(', ') || '(none)';
1445
+ lines.push(
1446
+ row(
1447
+ ` ${dim('•')} ${muted('Allowed:')} ${text(truncateToWidth(domainsStr, Math.max(10, innerW - 15)))}`,
1448
+ ),
1449
+ );
1450
+ const denyStr = config.network.deniedDomains.join(', ') || '(none)';
1451
+ lines.push(
1452
+ row(
1453
+ ` ${dim('•')} ${muted('Denied:')} ${text(truncateToWidth(denyStr, Math.max(10, innerW - 14)))}`,
1454
+ ),
1455
+ );
1456
+ if (sessionAllowedDomains.length > 0) {
1457
+ lines.push(
1458
+ row(
1459
+ ` ${dim('•')} ${muted('Session:')} ${theme.fg('accent', sessionAllowedDomains.join(', '))}`,
1460
+ ),
1461
+ );
1462
+ }
1463
+
1464
+ // Filesystem section
1465
+ lines.push(row(''));
1466
+ lines.push(row(`${'─'.repeat(innerW)}`));
1467
+ lines.push(row(` ${accent('Filesystem')}`));
1468
+ const denyReadStr = config.filesystem.denyRead.join(', ') || '(none)';
1469
+ lines.push(
1470
+ row(
1471
+ ` ${dim('•')} ${muted('Deny read:')} ${text(truncateToWidth(denyReadStr, Math.max(10, innerW - 16)))}`,
1472
+ ),
1473
+ );
1474
+ const allowReadStr = config.filesystem.allowRead.join(', ') || '(none)';
1475
+ lines.push(
1476
+ row(
1477
+ ` ${dim('•')} ${muted('Allow read:')} ${text(truncateToWidth(allowReadStr, Math.max(10, innerW - 17)))}`,
1478
+ ),
1479
+ );
1480
+ const allowWriteStr = config.filesystem.allowWrite.join(', ') || '(none)';
1481
+ lines.push(
1482
+ row(
1483
+ ` ${dim('•')} ${muted('Allow write:')} ${text(truncateToWidth(allowWriteStr, Math.max(10, innerW - 18)))}`,
1484
+ ),
1485
+ );
1486
+ const denyWriteStr = config.filesystem.denyWrite.join(', ') || '(none)';
1487
+ lines.push(
1488
+ row(
1489
+ ` ${dim('•')} ${muted('Deny write:')} ${text(truncateToWidth(denyWriteStr, Math.max(10, innerW - 17)))}`,
1490
+ ),
1491
+ );
1492
+
1493
+ // Session allowances
1494
+ if (sessionAllowedReadPaths.length > 0 || sessionAllowedWritePaths.length > 0) {
1495
+ lines.push(row(''));
1496
+ if (sessionAllowedReadPaths.length > 0) {
1497
+ lines.push(
1498
+ row(
1499
+ ` ${dim('•')} ${muted('Session read:')} ${theme.fg('accent', sessionAllowedReadPaths.join(', '))}`,
1500
+ ),
1501
+ );
1502
+ }
1503
+ if (sessionAllowedWritePaths.length > 0) {
1504
+ lines.push(
1505
+ row(
1506
+ ` ${dim('•')} ${muted('Session write:')} ${theme.fg('accent', sessionAllowedWritePaths.join(', '))}`,
1507
+ ),
1508
+ );
1509
+ }
1510
+ }
1511
+
1512
+ // Footer
1513
+ lines.push(row(''));
1514
+ lines.push(row(` ${dim('esc')} ${muted('or any key to close')}`));
1515
+
1516
+ // Bottom border
1517
+ lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
1518
+
1519
+ return lines;
1520
+ },
1521
+
1522
+ handleInput(): void {
1523
+ done(undefined);
1524
+ },
1525
+
1526
+ invalidate(): void {},
1527
+ };
1528
+ },
1529
+ {
1530
+ overlay: true,
1531
+ overlayOptions: {
1532
+ anchor: 'center',
1533
+ width: 78,
1534
+ margin: 2,
1535
+ },
1536
+ },
1537
+ );
1287
1538
  },
1288
1539
  });
1289
1540
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
- "@jarkkojs/landstrip": "^0.10.1"
34
+ "@jarkkojs/landstrip": "^0.10.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",