pi-landstrip 0.15.5 → 0.15.7

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 +103 -167
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  BashToolInput,
11
11
  ExtensionAPI,
12
12
  ExtensionContext,
13
+ Theme,
13
14
  } from '@earendil-works/pi-coding-agent';
14
15
 
15
16
  import { binaryPath } from '@landstrip/landstrip';
@@ -95,6 +96,7 @@ type LandstripTrap =
95
96
  interface LandstripBashCallbacks {
96
97
  onStderr?: (data: Buffer) => void;
97
98
  onErrorFd?: (data: Buffer) => void;
99
+ promptOnBlock?: boolean;
98
100
  }
99
101
 
100
102
  const LANDSTRIP_VERSION = [0, 15, 9] as const;
@@ -362,7 +364,9 @@ function matchesPattern(filePath: string, patterns: string[]): boolean {
362
364
  const absPattern = pattern.includes('*') ? expandPath(pattern) : canonicalizePath(pattern);
363
365
 
364
366
  if (pattern.includes('*')) {
365
- const escaped = absPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
367
+ const escaped = absPattern
368
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
369
+ .replace(/\*\*\/|\*\*|\*/g, (token) => (token === '**/' ? '(?:.*/)?' : '.*'));
366
370
  return new RegExp(`^${escaped}$`).test(abs);
367
371
  }
368
372
 
@@ -438,11 +442,6 @@ function extractNativeWriteDeniedPath(output: string, cwd: string): string | nul
438
442
  );
439
443
  if (match) return normalizePathMatch(match[1], cwd);
440
444
 
441
- match = output.match(
442
- /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
443
- );
444
- if (match) return normalizePathMatch(match[1], cwd);
445
-
446
445
  match = output.match(
447
446
  /^[a-zA-Z0-9_-]+: cannot create(?: directory)? '?([^'\n]+?)'?(?: for writing)?: (?:Operation not permitted|Permission denied)$/m,
448
447
  );
@@ -580,6 +579,25 @@ function setTuiStatus(ctx: ExtensionContext, key: string, value: string | undefi
580
579
  ctx.ui.setStatus(key, value);
581
580
  }
582
581
 
582
+ function boxTop(theme: Theme, width: number, title: string): string {
583
+ const label = theme.fg('accent', ` ${title} `);
584
+ const fill = theme.fg('border', '─'.repeat(Math.max(0, width - 4 - visibleWidth(label))));
585
+ return `${theme.fg('border', '╭─')}${label}${fill}${theme.fg('border', '─╮')}`;
586
+ }
587
+
588
+ function boxRow(theme: Theme, width: number, content = ''): string {
589
+ const innerW = Math.max(1, width - 4);
590
+ const border = theme.fg('border', '│');
591
+ const line = truncateToWidth(content, innerW);
592
+ const pad = Math.max(0, innerW - visibleWidth(line));
593
+ return `${border} ${line}${' '.repeat(pad)} ${border}`;
594
+ }
595
+
596
+ function boxBottom(theme: Theme, width: number): string {
597
+ const border = (s: string) => theme.fg('border', s);
598
+ return `${border('╰')}${border('─'.repeat(Math.max(0, width - 2)))}${border('╯')}`;
599
+ }
600
+
583
601
  async function showPermissionPrompt(
584
602
  ctx: ExtensionContext,
585
603
  title: string,
@@ -598,28 +616,14 @@ async function showPermissionPrompt(
598
616
 
599
617
  return {
600
618
  render(width: number): string[] {
601
- const innerW = width - 4;
619
+ const innerW = Math.max(1, width - 4);
602
620
  const lines: string[] = [];
603
- const border = theme.fg('border', '│');
604
621
  const dim = (s: string) => theme.fg('dim', s);
605
- const borderFg = (s: string) => theme.fg('border', s);
606
-
607
- // Top border with title
608
- const label = ' Sandbox ';
609
- const topLeft = borderFg('╭─');
610
- const topRight = borderFg('─╮');
611
- const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(label))));
612
- lines.push(`${topLeft}${theme.fg('accent', label)}${topFill}${topRight}`);
613
-
614
- // Blank spacing
615
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
616
622
 
617
- // Title line
618
- const titleText = truncateToWidth(theme.fg('warning', title), innerW);
619
- const titlePad = Math.max(0, innerW - visibleWidth(titleText));
620
- lines.push(`${border} ${titleText}${' '.repeat(titlePad)} ${border}`);
621
-
622
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
623
+ lines.push(boxTop(theme, width, 'Sandbox'));
624
+ lines.push(boxRow(theme, width));
625
+ lines.push(boxRow(theme, width, theme.fg('warning', title)));
626
+ lines.push(boxRow(theme, width));
623
627
 
624
628
  // Options
625
629
  for (let i = 0; i < options.length; i++) {
@@ -629,11 +633,11 @@ async function showPermissionPrompt(
629
633
 
630
634
  // Section divider before the permanent options (index 2 and 3)
631
635
  if (i === 2) {
632
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
636
+ lines.push(boxRow(theme, width));
633
637
  const secLabel = ' Permanent ';
634
638
  const secDash = '─'.repeat(Math.max(0, innerW - visibleWidth(secLabel)));
635
- lines.push(`${border} ${dim(secDash + secLabel)} ${border}`);
636
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
639
+ lines.push(boxRow(theme, width, dim(secDash + secLabel)));
640
+ lines.push(boxRow(theme, width));
637
641
  }
638
642
 
639
643
  // Key badge
@@ -668,25 +672,16 @@ async function showPermissionPrompt(
668
672
  }
669
673
 
670
674
  const fullLine = ` ${cursor} ${keyBadge} ${label}${hint}`;
671
- const line = truncateToWidth(fullLine, innerW);
672
- const pad = Math.max(0, innerW - visibleWidth(line));
673
- lines.push(`${border} ${line}${' '.repeat(pad)} ${border}`);
675
+ lines.push(boxRow(theme, width, fullLine));
674
676
  }
675
677
 
676
678
  // Footer
677
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
679
+ lines.push(boxRow(theme, width));
678
680
  const footerText = pendingAction
679
681
  ? '↑↓ navigate enter confirm esc cancel'
680
682
  : '↑↓ navigate enter select esc dismiss';
681
- const footerLine = dim(footerText);
682
- const footerPad = Math.max(0, innerW - visibleWidth(footerLine));
683
- lines.push(`${border} ${footerLine}${' '.repeat(footerPad)} ${border}`);
684
-
685
- // Bottom border
686
- const botLeft = borderFg('╰');
687
- const botRight = borderFg('╯');
688
- const botFill = borderFg('─'.repeat(width - 2));
689
- lines.push(`${botLeft}${botFill}${botRight}`);
683
+ lines.push(boxRow(theme, width, dim(footerText)));
684
+ lines.push(boxBottom(theme, width));
690
685
 
691
686
  return lines;
692
687
  },
@@ -896,18 +891,15 @@ export function createLandstripIntegration(
896
891
  sessionAllowedWritePaths.length = 0;
897
892
  }
898
893
 
899
- function getEffectiveAllowedDomains(cwd: string): string[] {
900
- const config = loadConfig(cwd);
894
+ function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
901
895
  return [...config.network.allowedDomains, ...sessionAllowedDomains];
902
896
  }
903
897
 
904
- function getEffectiveAllowRead(cwd: string): string[] {
905
- const config = loadConfig(cwd);
898
+ function getEffectiveAllowRead(config: SandboxConfig): string[] {
906
899
  return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
907
900
  }
908
901
 
909
- function getEffectiveAllowWrite(cwd: string): string[] {
910
- const config = loadConfig(cwd);
902
+ function getEffectiveAllowWrite(config: SandboxConfig): string[] {
911
903
  return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
912
904
  }
913
905
 
@@ -952,7 +944,7 @@ export function createLandstripIntegration(
952
944
  const config = loadConfig(cwd);
953
945
 
954
946
  if (domainMatchesAny(domain, config.network.deniedDomains)) return false;
955
- if (domainMatchesAny(domain, getEffectiveAllowedDomains(cwd))) return true;
947
+ if (domainMatchesAny(domain, getEffectiveAllowedDomains(config))) return true;
956
948
 
957
949
  const choice = await promptDomainBlock(ctx, domain);
958
950
  if (choice === 'abort') return false;
@@ -974,8 +966,8 @@ export function createLandstripIntegration(
974
966
  },
975
967
  filesystem: {
976
968
  denyRead: config.filesystem.denyRead,
977
- allowRead: getEffectiveAllowRead(cwd),
978
- allowWrite: getEffectiveAllowWrite(cwd),
969
+ allowRead: getEffectiveAllowRead(config),
970
+ allowWrite: getEffectiveAllowWrite(config),
979
971
  denyWrite: config.filesystem.denyWrite,
980
972
  },
981
973
  };
@@ -1219,7 +1211,6 @@ export function createLandstripIntegration(
1219
1211
  }
1220
1212
 
1221
1213
  signal?.addEventListener('abort', onAbort, { once: true });
1222
- const resolvedQueryIds = new Set<number>();
1223
1214
  let stderrAcc = '';
1224
1215
  let errorFdAcc = '';
1225
1216
 
@@ -1232,109 +1223,70 @@ export function createLandstripIntegration(
1232
1223
  trapSocket.on('data', (data: Buffer) => {
1233
1224
  errorFdAcc += data.toString('utf8');
1234
1225
  callbacks.onErrorFd?.(data);
1235
- // Process query traps in real-time.
1236
- if (ctx.hasUI) {
1237
- const traps = parseLandstripTraps(errorFdAcc);
1238
- for (const trap of traps) {
1239
- if (
1240
- trap.kind === 'filesystem' &&
1241
- (trap.operation === 'write' || trap.operation === 'read') &&
1242
- 'state' in trap &&
1243
- (trap as any).state === 'query' &&
1244
- 'query_id' in trap
1245
- ) {
1246
- const queryId = (trap as any).query_id as number;
1247
- if (!resolvedQueryIds.has(queryId)) {
1248
- resolvedQueryIds.add(queryId);
1249
- handleFsQuery(trapSocket, queryId, trap.operation, trap.file, cwd, ctx).catch(
1250
- () => {},
1251
- );
1252
- }
1253
- }
1254
- }
1255
- }
1256
1226
  });
1257
1227
 
1258
- async function handleFsQuery(
1259
- socket: NetSocket,
1260
- queryId: number,
1261
- operation: 'read' | 'write',
1262
- file: string,
1263
- cwd: string,
1264
- ctx: ExtensionContext,
1265
- ): Promise<void> {
1266
- const choice =
1267
- operation === 'read'
1268
- ? await promptReadBlock(ctx, file)
1269
- : await promptWriteBlock(ctx, file);
1270
- if (choice !== 'abort') {
1271
- if (operation === 'read') await applyReadChoice(choice, file, cwd);
1272
- else await applyWriteChoice(choice, file, cwd);
1273
- }
1274
- const action = choice === 'abort' ? 'deny' : 'allow';
1275
- const response = JSON.stringify({ query_id: queryId, action }) + '\n';
1276
- if (!socket.destroyed) {
1277
- socket.write(response);
1278
- }
1279
- }
1280
-
1281
1228
  child.on('error', (error) => {
1282
1229
  cleanup();
1283
1230
  reject(error);
1284
1231
  });
1285
1232
 
1286
- child.on('close', async (code) => {
1287
- cleanup();
1288
- if (signal?.aborted) {
1289
- reject(new Error('aborted'));
1290
- return;
1291
- }
1292
- if (timedOut) {
1293
- reject(new Error(`timeout:${timeout}`));
1294
- return;
1295
- }
1296
-
1297
- const errorOutput = errorFdAcc || stderrAcc;
1298
-
1299
- const blockedPath =
1300
- extractBlockedPath(errorOutput, cwd) ??
1301
- (errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
1302
- const blockedWritePath =
1303
- extractBlockedWritePath(errorOutput, cwd) ??
1304
- (errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
1305
- if (blockedPath && ctx.hasUI) {
1306
- const config = loadConfig(cwd);
1307
- const isDeniedByDenyRead = matchesPattern(blockedPath, config.filesystem.denyRead);
1308
- const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(cwd));
1309
- const isWriteAllowed = !shouldPromptForWrite(
1310
- blockedPath,
1311
- getEffectiveAllowWrite(cwd),
1312
- matchesPattern,
1313
- );
1233
+ child.on('close', (code) => {
1234
+ void (async () => {
1235
+ cleanup();
1236
+ if (signal?.aborted) {
1237
+ reject(new Error('aborted'));
1238
+ return;
1239
+ }
1240
+ if (timedOut) {
1241
+ reject(new Error(`timeout:${timeout}`));
1242
+ return;
1243
+ }
1314
1244
 
1315
- if (blockedWritePath === blockedPath && !isWriteAllowed) {
1316
- const choice = await promptWriteBlock(ctx, blockedPath);
1317
- if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1318
- } else if (isDeniedByDenyRead || !isReadAllowed) {
1319
- const choice = await promptReadBlock(
1320
- ctx,
1245
+ const errorOutput = errorFdAcc || stderrAcc;
1246
+
1247
+ const blockedPath =
1248
+ extractBlockedPath(errorOutput, cwd) ??
1249
+ (errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
1250
+ const blockedWritePath =
1251
+ extractBlockedWritePath(errorOutput, cwd) ??
1252
+ (errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
1253
+ if (blockedPath && ctx.hasUI && callbacks.promptOnBlock) {
1254
+ const config = loadConfig(cwd);
1255
+ const isDeniedByDenyRead = matchesPattern(
1321
1256
  blockedPath,
1322
- isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
1257
+ config.filesystem.denyRead,
1323
1258
  );
1324
- if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1325
- } else if (!isWriteAllowed) {
1326
- const choice = await promptWriteBlock(ctx, blockedPath);
1327
- if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1328
- }
1329
- } else if (!blockedPath && ctx.hasUI) {
1330
- const landstripErrors = parseLandstripTraps(errorOutput);
1331
- if (landstripErrors.length > 0) {
1332
- const formatted = formatLandstripTraps(landstripErrors);
1333
- notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
1259
+ const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(config));
1260
+ const isWriteAllowed = !shouldPromptForWrite(
1261
+ blockedPath,
1262
+ getEffectiveAllowWrite(config),
1263
+ matchesPattern,
1264
+ );
1265
+
1266
+ if (blockedWritePath === blockedPath && !isWriteAllowed) {
1267
+ const choice = await promptWriteBlock(ctx, blockedPath);
1268
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1269
+ } else if (isDeniedByDenyRead || !isReadAllowed) {
1270
+ const choice = await promptReadBlock(
1271
+ ctx,
1272
+ blockedPath,
1273
+ isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
1274
+ );
1275
+ if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1276
+ } else if (!isWriteAllowed) {
1277
+ const choice = await promptWriteBlock(ctx, blockedPath);
1278
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1279
+ }
1280
+ } else if (!blockedPath && ctx.hasUI) {
1281
+ const landstripErrors = parseLandstripTraps(errorOutput);
1282
+ if (landstripErrors.length > 0) {
1283
+ const formatted = formatLandstripTraps(landstripErrors);
1284
+ notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
1285
+ }
1334
1286
  }
1335
- }
1336
1287
 
1337
- resolvePromise({ exitCode: code });
1288
+ resolvePromise({ exitCode: code });
1289
+ })().catch(reject);
1338
1290
  });
1339
1291
  })().catch(reject);
1340
1292
  });
@@ -1380,7 +1332,7 @@ export function createLandstripIntegration(
1380
1332
  return null;
1381
1333
  }
1382
1334
 
1383
- if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(ctx.cwd), matchesPattern)) {
1335
+ if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config), matchesPattern)) {
1384
1336
  const choice = await promptWriteBlock(ctx, blockedPath);
1385
1337
  if (choice === 'abort') return null;
1386
1338
  await applyWriteChoice(choice, blockedPath, ctx.cwd);
@@ -1412,8 +1364,8 @@ export function createLandstripIntegration(
1412
1364
  ): Promise<AgentToolResult<BashToolDetails | undefined> | null> => {
1413
1365
  if (!ctx.hasUI) return null;
1414
1366
 
1415
- if (!matchesPattern(blockedPath, getEffectiveAllowRead(ctx.cwd))) {
1416
- const config = loadConfig(ctx.cwd);
1367
+ const config = loadConfig(ctx.cwd);
1368
+ if (!matchesPattern(blockedPath, getEffectiveAllowRead(config))) {
1417
1369
  const choice = await promptReadBlock(
1418
1370
  ctx,
1419
1371
  blockedPath,
@@ -1648,7 +1600,7 @@ export function createLandstripIntegration(
1648
1600
  }
1649
1601
  }
1650
1602
 
1651
- return { operations: createLandstripBashOps(ctx) };
1603
+ return { operations: createLandstripBashOps(ctx, { promptOnBlock: true }) };
1652
1604
  });
1653
1605
 
1654
1606
  pi.on('tool_call', async (event, ctx) => {
@@ -1672,7 +1624,7 @@ export function createLandstripIntegration(
1672
1624
 
1673
1625
  if (isToolCallEventType('read', event)) {
1674
1626
  const filePath = canonicalizePath(event.input.path);
1675
- if (!matchesPattern(filePath, getEffectiveAllowRead(ctx.cwd))) {
1627
+ if (!matchesPattern(filePath, getEffectiveAllowRead(config))) {
1676
1628
  const choice = await promptReadBlock(ctx, filePath);
1677
1629
  if (choice === 'abort') {
1678
1630
  return {
@@ -1696,7 +1648,7 @@ export function createLandstripIntegration(
1696
1648
  };
1697
1649
  }
1698
1650
 
1699
- if (shouldPromptForWrite(filePath, getEffectiveAllowWrite(ctx.cwd), matchesPattern)) {
1651
+ if (shouldPromptForWrite(filePath, getEffectiveAllowWrite(config), matchesPattern)) {
1700
1652
  const choice = await promptWriteBlock(ctx, filePath);
1701
1653
  if (choice === 'abort') {
1702
1654
  return {
@@ -1742,7 +1694,6 @@ export function createLandstripIntegration(
1742
1694
  const muted = (s: string) => theme.fg('muted', s);
1743
1695
  const accent = (s: string) => theme.fg('accent', s);
1744
1696
  const text = (s: string) => theme.fg('text', s);
1745
- const borderFg = (s: string) => theme.fg('border', s);
1746
1697
 
1747
1698
  function sandboxStatus(): { color: 'success' | 'warning'; label: string } {
1748
1699
  if (noSandboxFlag) return { color: 'warning', label: 'Disabled (--no-sandbox)' };
@@ -1755,29 +1706,16 @@ export function createLandstripIntegration(
1755
1706
  return v ? theme.fg('warning', 'yes') : theme.fg('success', 'no');
1756
1707
  }
1757
1708
 
1758
- function makeRow(content: string, innerW: number, border: string): string {
1759
- const line = truncateToWidth(content, innerW);
1760
- const pad = Math.max(0, innerW - visibleWidth(line));
1761
- return `${border} ${line}${' '.repeat(pad)} ${border}`;
1762
- }
1763
-
1764
1709
  return {
1765
1710
  render(width: number): string[] {
1766
1711
  const innerW = Math.max(1, width - 4);
1767
- const border = borderFg('');
1768
- const row = (content: string) => makeRow(content, innerW, border);
1712
+ const row = (content = '') => boxRow(theme, width, content);
1769
1713
  const lines: string[] = [];
1770
1714
  const status = sandboxStatus();
1771
1715
  const toggleValue = config.enabled
1772
1716
  ? theme.fg('success', 'enabled')
1773
1717
  : theme.fg('warning', 'disabled');
1774
1718
 
1775
- function topBorder(titleText: string): string {
1776
- const title = accent(` ${titleText} `);
1777
- const fill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
1778
- return `${borderFg('╭─')}${title}${fill}${borderFg('─╮')}`;
1779
- }
1780
-
1781
1719
  function section(titleText: string, detail?: string): void {
1782
1720
  lines.push(row(''));
1783
1721
  lines.push(row(`${accent(titleText)}${detail ? dim(` · ${detail}`) : ''}`));
@@ -1792,7 +1730,7 @@ export function createLandstripIntegration(
1792
1730
  return text(truncateToWidth(value, Math.max(10, maxWidth)));
1793
1731
  }
1794
1732
 
1795
- lines.push(topBorder('Sandbox'));
1733
+ lines.push(boxTop(theme, width, 'Sandbox'));
1796
1734
 
1797
1735
  const statusDot = theme.fg(status.color, '●');
1798
1736
  const pathSnippet = text(truncateToWidth(binaryPath(), Math.max(20, innerW - 28)));
@@ -1834,9 +1772,7 @@ export function createLandstripIntegration(
1834
1772
  `${dim('t')} ${muted('toggle persisted setting')} ${dim('esc')} ${muted('close')}`,
1835
1773
  ),
1836
1774
  );
1837
- lines.push(
1838
- `${borderFg('╰')}${borderFg('─'.repeat(Math.max(0, width - 2)))}${borderFg('╯')}`,
1839
- );
1775
+ lines.push(boxBottom(theme, width));
1840
1776
 
1841
1777
  return lines;
1842
1778
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",