pi-landstrip 0.3.3 → 0.5.0

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 +400 -144
  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, 9, 7] 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,36 +337,67 @@ 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[] {
349
367
  const errors: LandstripErrorResponse[] = [];
350
368
 
351
- for (const line of output.split('\n')) {
352
- try {
353
- const parsed = JSON.parse(line);
369
+ for (const block of output.trim().split(/\n\n+/)) {
370
+ const fields: Record<string, string> = {};
371
+
372
+ for (const line of block.split('\n')) {
373
+ const colonIndex = line.indexOf(':');
374
+ if (colonIndex === -1) continue;
375
+ const key = line.slice(0, colonIndex).trim();
376
+ const value = line.slice(colonIndex + 1).trim();
377
+ if (key.length > 0 && value.length > 0) fields[key] = value;
378
+ }
379
+
380
+ if (
381
+ fields.category &&
382
+ ['policy', 'tool', 'platform', 'system'].includes(fields.category) &&
383
+ fields.message
384
+ ) {
385
+ const error: LandstripErrorResponse = {
386
+ category: fields.category as LandstripErrorResponse['category'],
387
+ message: fields.message,
388
+ };
389
+
390
+ if (fields.file) error.file = fields.file;
391
+ if (fields.program) error.program = fields.program;
354
392
 
355
393
  if (
356
- typeof parsed === 'object' &&
357
- parsed !== null &&
358
- typeof parsed.category === 'string' &&
359
- ['policy', 'tool', 'platform', 'system'].includes(parsed.category) &&
360
- (parsed.type === undefined ||
361
- (typeof parsed.type === 'string' &&
362
- ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(parsed.type))) &&
363
- typeof parsed.message === 'string' &&
364
- parsed.message.length > 0
394
+ fields.type &&
395
+ ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
365
396
  ) {
366
- errors.push(parsed as LandstripErrorResponse);
397
+ error.type = fields.type as LandstripErrorResponse['type'];
367
398
  }
368
- } catch {
369
- // ignore non-JSON lines
399
+
400
+ errors.push(error);
370
401
  }
371
402
  }
372
403
 
@@ -401,99 +432,168 @@ async function showPermissionPrompt(
401
432
  ): Promise<PermissionChoice> {
402
433
  if (!ctx.hasUI) return 'abort';
403
434
 
404
- const result = await ctx.ui.custom<PermissionChoice>((tui, theme, _kb, done) => {
405
- let selectedIndex = 0;
406
- 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;
407
439
 
408
- function resolveChoice(action: PermissionChoice): void {
409
- done(action);
410
- }
440
+ function resolveChoice(action: PermissionChoice): void {
441
+ done(action);
442
+ }
411
443
 
412
- return {
413
- render(width: number): string[] {
414
- const lines: string[] = [];
415
- lines.push(truncateToWidth(theme.fg('warning', title), width));
416
- lines.push('');
417
-
418
- for (let i = 0; i < options.length; i++) {
419
- const option = options[i];
420
- const isSelected = i === selectedIndex;
421
- const isPending = pendingAction === option.action;
422
- const prefix = isSelected ? ' -> ' : ' ';
423
- const keyHint = theme.fg('accent', `[${option.key}]`);
424
- let label = option.label;
425
-
426
- if (option.hint) label += ` ${theme.fg('dim', option.hint)}`;
427
- if (isPending) label += ` ${theme.fg('warning', '-> press Enter to confirm')}`;
428
-
429
- lines.push(truncateToWidth(`${prefix}${keyHint} ${label}`, width));
430
- }
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
+ }
431
483
 
432
- lines.push('');
433
- lines.push(
434
- truncateToWidth(
435
- theme.fg(
436
- 'dim',
437
- pendingAction
438
- ? 'up/down navigate enter confirm esc cancel'
439
- : 'up/down navigate enter select esc cancel',
440
- ),
441
- width,
442
- ),
443
- );
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
+ }
444
498
 
445
- return lines;
446
- },
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
+ }
447
508
 
448
- handleInput(data: string): void {
449
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
450
- resolveChoice('abort');
451
- return;
452
- }
509
+ // Hint
510
+ let hint = '';
511
+ if (option.hint && !isPending) {
512
+ hint = ' ' + dim(option.hint);
513
+ }
453
514
 
454
- if (matchesKey(data, Key.enter)) {
455
- resolveChoice(pendingAction ?? options[selectedIndex]?.action ?? 'abort');
456
- return;
457
- }
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
+ }
458
520
 
459
- if (matchesKey(data, Key.up)) {
460
- selectedIndex = Math.max(0, selectedIndex - 1);
461
- pendingAction = null;
462
- tui.requestRender();
463
- return;
464
- }
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
+ }
465
544
 
466
- if (matchesKey(data, Key.down)) {
467
- selectedIndex = Math.min(options.length - 1, selectedIndex + 1);
468
- pendingAction = null;
469
- tui.requestRender();
470
- return;
471
- }
545
+ if (matchesKey(data, Key.enter)) {
546
+ resolveChoice(pendingAction ?? options[selectedIndex]?.action ?? 'abort');
547
+ return;
548
+ }
472
549
 
473
- for (let i = 0; i < options.length; i++) {
474
- 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
+ }
475
556
 
476
- if (data === option.key) {
477
- resolveChoice(option.action);
557
+ if (matchesKey(data, Key.down)) {
558
+ selectedIndex = Math.min(options.length - 1, selectedIndex + 1);
559
+ pendingAction = null;
560
+ tui.requestRender();
478
561
  return;
479
562
  }
480
563
 
481
- if (data.toLowerCase() === option.key.toLowerCase()) {
482
- if (option.confirm) {
483
- pendingAction = option.action;
484
- selectedIndex = i;
485
- } else {
564
+ for (let i = 0; i < options.length; i++) {
565
+ const option = options[i];
566
+
567
+ if (data === option.key) {
486
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;
487
581
  }
488
- tui.requestRender();
489
- return;
490
582
  }
491
- }
492
- },
583
+ },
493
584
 
494
- invalidate(): void {},
495
- };
496
- });
585
+ invalidate(): void {},
586
+ };
587
+ },
588
+ {
589
+ overlay: true,
590
+ overlayOptions: {
591
+ anchor: 'center',
592
+ width: 72,
593
+ margin: 2,
594
+ },
595
+ },
596
+ );
497
597
 
498
598
  return result ?? 'abort';
499
599
  }
@@ -911,8 +1011,11 @@ export function createLandstripIntegration(
911
1011
  }
912
1012
 
913
1013
  signal?.addEventListener('abort', onAbort, { once: true });
1014
+ let stderrAcc = '';
1015
+
914
1016
  child.stdout?.on('data', onData);
915
1017
  child.stderr?.on('data', (data: Buffer) => {
1018
+ stderrAcc += data.toString('utf8');
916
1019
  onStderr(data);
917
1020
  onData(data);
918
1021
  });
@@ -922,15 +1025,37 @@ export function createLandstripIntegration(
922
1025
  reject(error);
923
1026
  });
924
1027
 
925
- child.on('close', (code) => {
1028
+ child.on('close', async (code) => {
926
1029
  cleanup();
927
1030
  if (signal?.aborted) {
928
1031
  reject(new Error('aborted'));
929
- } else if (timedOut) {
1032
+ return;
1033
+ }
1034
+ if (timedOut) {
930
1035
  reject(new Error(`timeout:${timeout}`));
931
- } else {
932
- resolvePromise({ exitCode: code });
1036
+ return;
933
1037
  }
1038
+
1039
+ const blockedPath = extractBlockedPath(stderrAcc, cwd);
1040
+ if (blockedPath && ctx.hasUI) {
1041
+ const config = loadConfig(cwd);
1042
+ const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(cwd));
1043
+ const isWriteAllowed = !shouldPromptForWrite(
1044
+ blockedPath,
1045
+ getEffectiveAllowWrite(cwd),
1046
+ matchesPattern,
1047
+ );
1048
+
1049
+ if (!isReadAllowed) {
1050
+ const choice = await promptReadBlock(ctx, blockedPath);
1051
+ if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1052
+ } else if (!isWriteAllowed) {
1053
+ const choice = await promptWriteBlock(ctx, blockedPath);
1054
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1055
+ }
1056
+ }
1057
+
1058
+ resolvePromise({ exitCode: code });
934
1059
  });
935
1060
  });
936
1061
  },
@@ -1024,18 +1149,28 @@ export function createLandstripIntegration(
1024
1149
  }
1025
1150
 
1026
1151
  function enableStatus(ctx: ExtensionContext, config: SandboxConfig): void {
1027
- const networkLabel = config.network.allowNetwork
1028
- ? 'unrestricted'
1029
- : allowsAllDomains(config.network.allowedDomains)
1030
- ? 'all domains'
1031
- : `${config.network.allowedDomains.length} domains`;
1032
- ctx.ui.setStatus(
1033
- 'sandbox',
1034
- ctx.ui.theme.fg(
1035
- 'accent',
1036
- `Sandbox: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
1037
- ),
1038
- );
1152
+ const theme = ctx.ui.theme;
1153
+ const dot = theme.fg('success', '●');
1154
+ const label = theme.fg('text', 'Sandbox');
1155
+
1156
+ let networkLabel: string;
1157
+ let networkColor: 'warning' | 'accent';
1158
+ if (config.network.allowNetwork) {
1159
+ networkLabel = 'unrestricted';
1160
+ networkColor = 'warning';
1161
+ } else if (allowsAllDomains(config.network.allowedDomains)) {
1162
+ networkLabel = 'any domain';
1163
+ networkColor = 'warning';
1164
+ } else {
1165
+ networkLabel = `${config.network.allowedDomains.length} domains`;
1166
+ networkColor = 'accent';
1167
+ }
1168
+
1169
+ const sep = theme.fg('dim', '·');
1170
+ const net = theme.fg(networkColor, networkLabel);
1171
+ const write = theme.fg('accent', `${config.filesystem.allowWrite.length} write paths`);
1172
+
1173
+ ctx.ui.setStatus('sandbox', `${dot} ${label} ${sep} ${net} ${sep} ${write}`);
1039
1174
  }
1040
1175
 
1041
1176
  function enableSandbox(ctx: ExtensionContext): boolean {
@@ -1062,7 +1197,7 @@ export function createLandstripIntegration(
1062
1197
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
1063
1198
  sandboxEnabled = false;
1064
1199
  sandboxReady = false;
1065
- ctx.ui.notify(`landstrip 0.9.7 or newer is required; found: ${version}`, 'error');
1200
+ ctx.ui.notify(`landstrip 0.10.2 or newer is required; found: ${version}`, 'error');
1066
1201
  return false;
1067
1202
  }
1068
1203
 
@@ -1228,7 +1363,7 @@ export function createLandstripIntegration(
1228
1363
 
1229
1364
  sandboxEnabled = false;
1230
1365
  sandboxReady = false;
1231
- ctx.ui.setStatus('sandbox', '');
1366
+ ctx.ui.setStatus('sandbox', undefined);
1232
1367
  ctx.ui.notify('Sandbox disabled', 'info');
1233
1368
  },
1234
1369
  });
@@ -1243,34 +1378,155 @@ export function createLandstripIntegration(
1243
1378
 
1244
1379
  const config = loadConfig(ctx.cwd);
1245
1380
  const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
1246
- const lines = [
1247
- 'Sandbox Configuration',
1248
- ` Project config: ${projectPath}`,
1249
- ` Global config: ${globalPath}`,
1250
- ` landstrip: ${binaryPath()}`,
1251
- '',
1252
- `Network (bash ${config.network.allowNetwork ? 'unrestricted' : 'through HTTP proxy'}):`,
1253
- ` Allow network: ${config.network.allowNetwork}`,
1254
- ` Allowed domains: ${config.network.allowedDomains.join(', ') || '(none)'}`,
1255
- ` Denied domains: ${config.network.deniedDomains.join(', ') || '(none)'}`,
1256
- ...(sessionAllowedDomains.length > 0
1257
- ? [` Session allowed: ${sessionAllowedDomains.join(', ')}`]
1258
- : []),
1259
- '',
1260
- 'Filesystem (bash + read/write/edit tools):',
1261
- ` Deny Read: ${config.filesystem.denyRead.join(', ') || '(none)'}`,
1262
- ` Allow Read: ${config.filesystem.allowRead.join(', ') || '(none)'}`,
1263
- ` Allow Write: ${config.filesystem.allowWrite.join(', ') || '(none)'}`,
1264
- ` Deny Write: ${config.filesystem.denyWrite.join(', ') || '(none)'}`,
1265
- ...(sessionAllowedReadPaths.length > 0
1266
- ? [` Session read: ${sessionAllowedReadPaths.join(', ')}`]
1267
- : []),
1268
- ...(sessionAllowedWritePaths.length > 0
1269
- ? [` Session write: ${sessionAllowedWritePaths.join(', ')}`]
1270
- : []),
1271
- ];
1272
-
1273
- ctx.ui.notify(lines.join('\n'), 'info');
1381
+
1382
+ await ctx.ui.custom(
1383
+ (tui, theme, _kb, done) => {
1384
+ const dim = (s: string) => theme.fg('dim', s);
1385
+ const muted = (s: string) => theme.fg('muted', s);
1386
+ const accent = (s: string) => theme.fg('accent', s);
1387
+ const text = (s: string) => theme.fg('text', s);
1388
+ const borderFg = (s: string) => theme.fg('border', s);
1389
+
1390
+ function boolVal(v: boolean): string {
1391
+ return v ? theme.fg('warning', 'yes') : theme.fg('success', 'no');
1392
+ }
1393
+
1394
+ function makeRow(content: string, innerW: number, border: string): string {
1395
+ const line = truncateToWidth(content, innerW);
1396
+ const pad = Math.max(0, innerW - visibleWidth(line));
1397
+ return `${border} ${line}${' '.repeat(pad)} ${border}`;
1398
+ }
1399
+
1400
+ return {
1401
+ render(width: number): string[] {
1402
+ const innerW = width - 4;
1403
+ const border = borderFg('│');
1404
+ const row = (c: string) => makeRow(c, innerW, border);
1405
+ const lines: string[] = [];
1406
+
1407
+ // Top border
1408
+ const title = accent(' Sandbox Configuration ');
1409
+ const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
1410
+ lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
1411
+
1412
+ // Status
1413
+ const statusDot = theme.fg('success', '●');
1414
+ const pathSnippet = text(truncateToWidth(binaryPath(), Math.max(20, innerW - 27)));
1415
+ lines.push(
1416
+ row(
1417
+ ` ${statusDot} ${text('Active')} ${dim('·')} ${muted('landstrip:')} ${pathSnippet}`,
1418
+ ),
1419
+ );
1420
+
1421
+ // Config files
1422
+ lines.push(row(` ${dim('Config files:')}`));
1423
+ lines.push(row(` ${dim('project')} ${text(projectPath)}`));
1424
+ lines.push(row(` ${dim('global')} ${text(globalPath)}`));
1425
+
1426
+ // Network section
1427
+ lines.push(row(''));
1428
+ lines.push(row(`${'─'.repeat(innerW)}`));
1429
+ const netMode = config.network.allowNetwork ? ' (unrestricted)' : ' (proxied)';
1430
+ lines.push(row(` ${accent('Network')}${dim(netMode)}`));
1431
+ lines.push(
1432
+ row(
1433
+ ` ${dim('•')} ${muted('Allow network:')} ${boolVal(config.network.allowNetwork)}`,
1434
+ ),
1435
+ );
1436
+ const domainsStr = config.network.allowedDomains.join(', ') || '(none)';
1437
+ lines.push(
1438
+ row(
1439
+ ` ${dim('•')} ${muted('Allowed:')} ${text(truncateToWidth(domainsStr, Math.max(10, innerW - 15)))}`,
1440
+ ),
1441
+ );
1442
+ const denyStr = config.network.deniedDomains.join(', ') || '(none)';
1443
+ lines.push(
1444
+ row(
1445
+ ` ${dim('•')} ${muted('Denied:')} ${text(truncateToWidth(denyStr, Math.max(10, innerW - 14)))}`,
1446
+ ),
1447
+ );
1448
+ if (sessionAllowedDomains.length > 0) {
1449
+ lines.push(
1450
+ row(
1451
+ ` ${dim('•')} ${muted('Session:')} ${theme.fg('accent', sessionAllowedDomains.join(', '))}`,
1452
+ ),
1453
+ );
1454
+ }
1455
+
1456
+ // Filesystem section
1457
+ lines.push(row(''));
1458
+ lines.push(row(`${'─'.repeat(innerW)}`));
1459
+ lines.push(row(` ${accent('Filesystem')}`));
1460
+ const denyReadStr = config.filesystem.denyRead.join(', ') || '(none)';
1461
+ lines.push(
1462
+ row(
1463
+ ` ${dim('•')} ${muted('Deny read:')} ${text(truncateToWidth(denyReadStr, Math.max(10, innerW - 16)))}`,
1464
+ ),
1465
+ );
1466
+ const allowReadStr = config.filesystem.allowRead.join(', ') || '(none)';
1467
+ lines.push(
1468
+ row(
1469
+ ` ${dim('•')} ${muted('Allow read:')} ${text(truncateToWidth(allowReadStr, Math.max(10, innerW - 17)))}`,
1470
+ ),
1471
+ );
1472
+ const allowWriteStr = config.filesystem.allowWrite.join(', ') || '(none)';
1473
+ lines.push(
1474
+ row(
1475
+ ` ${dim('•')} ${muted('Allow write:')} ${text(truncateToWidth(allowWriteStr, Math.max(10, innerW - 18)))}`,
1476
+ ),
1477
+ );
1478
+ const denyWriteStr = config.filesystem.denyWrite.join(', ') || '(none)';
1479
+ lines.push(
1480
+ row(
1481
+ ` ${dim('•')} ${muted('Deny write:')} ${text(truncateToWidth(denyWriteStr, Math.max(10, innerW - 17)))}`,
1482
+ ),
1483
+ );
1484
+
1485
+ // Session allowances
1486
+ if (sessionAllowedReadPaths.length > 0 || sessionAllowedWritePaths.length > 0) {
1487
+ lines.push(row(''));
1488
+ if (sessionAllowedReadPaths.length > 0) {
1489
+ lines.push(
1490
+ row(
1491
+ ` ${dim('•')} ${muted('Session read:')} ${theme.fg('accent', sessionAllowedReadPaths.join(', '))}`,
1492
+ ),
1493
+ );
1494
+ }
1495
+ if (sessionAllowedWritePaths.length > 0) {
1496
+ lines.push(
1497
+ row(
1498
+ ` ${dim('•')} ${muted('Session write:')} ${theme.fg('accent', sessionAllowedWritePaths.join(', '))}`,
1499
+ ),
1500
+ );
1501
+ }
1502
+ }
1503
+
1504
+ // Footer
1505
+ lines.push(row(''));
1506
+ lines.push(row(` ${dim('esc')} ${muted('or any key to close')}`));
1507
+
1508
+ // Bottom border
1509
+ lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
1510
+
1511
+ return lines;
1512
+ },
1513
+
1514
+ handleInput(): void {
1515
+ done(undefined);
1516
+ },
1517
+
1518
+ invalidate(): void {},
1519
+ };
1520
+ },
1521
+ {
1522
+ overlay: true,
1523
+ overlayOptions: {
1524
+ anchor: 'center',
1525
+ width: 78,
1526
+ margin: 2,
1527
+ },
1528
+ },
1529
+ );
1274
1530
  },
1275
1531
  });
1276
1532
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
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.9.7"
34
+ "@jarkkojs/landstrip": "^0.10.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.78.0",