pi-landstrip 0.15.6 → 0.15.8

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 +154 -188
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
- /// <reference path="./landstrip.d.ts" />
5
-
6
4
  import type {
7
5
  AgentToolResult,
8
6
  AgentToolUpdateCallback,
@@ -10,6 +8,7 @@ import type {
10
8
  BashToolInput,
11
9
  ExtensionAPI,
12
10
  ExtensionContext,
11
+ Theme,
13
12
  } from '@earendil-works/pi-coding-agent';
14
13
 
15
14
  import { binaryPath } from '@landstrip/landstrip';
@@ -69,6 +68,15 @@ interface SandboxConfig {
69
68
  filesystem: SandboxFilesystemConfig;
70
69
  }
71
70
 
71
+ type SandboxFilesystemConfigFile = Partial<SandboxFilesystemConfig>;
72
+ type SandboxNetworkConfigFile = Partial<SandboxNetworkConfig>;
73
+
74
+ interface SandboxConfigFile {
75
+ enabled?: boolean;
76
+ network?: SandboxNetworkConfigFile;
77
+ filesystem?: SandboxFilesystemConfigFile;
78
+ }
79
+
72
80
  type SandboxConfigScope = 'global' | 'project';
73
81
 
74
82
  interface LandstripPolicy {
@@ -95,6 +103,7 @@ type LandstripTrap =
95
103
  interface LandstripBashCallbacks {
96
104
  onStderr?: (data: Buffer) => void;
97
105
  onErrorFd?: (data: Buffer) => void;
106
+ promptOnBlock?: boolean;
98
107
  }
99
108
 
100
109
  const LANDSTRIP_VERSION = [0, 15, 9] as const;
@@ -153,8 +162,8 @@ function loadConfig(cwd: string): SandboxConfig {
153
162
  const projectConfigPath = join(cwd, '.pi', 'sandbox.json');
154
163
  const globalConfigPath = join(getAgentDir(), 'sandbox.json');
155
164
 
156
- let globalConfig: Partial<SandboxConfig> = {};
157
- let projectConfig: Partial<SandboxConfig> = {};
165
+ let globalConfig: SandboxConfigFile = {};
166
+ let projectConfig: SandboxConfigFile = {};
158
167
 
159
168
  if (existsSync(globalConfigPath)) {
160
169
  try {
@@ -180,7 +189,7 @@ function mergeArray(base: string[], override?: string[]): string[] {
180
189
  return [...new Set([...base, ...override])];
181
190
  }
182
191
 
183
- function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
192
+ function deepMerge(base: SandboxConfig, overrides: SandboxConfigFile): SandboxConfig {
184
193
  const network = overrides.network;
185
194
  const filesystem = overrides.filesystem;
186
195
 
@@ -210,7 +219,7 @@ function getConfigPaths(cwd: string): { globalPath: string; projectPath: string
210
219
  };
211
220
  }
212
221
 
213
- function readOrEmptyConfig(configPath: string): Partial<SandboxConfig> {
222
+ function readOrEmptyConfig(configPath: string): SandboxConfigFile {
214
223
  if (!existsSync(configPath)) return {};
215
224
  try {
216
225
  return JSON.parse(readFileSync(configPath, 'utf-8'));
@@ -219,7 +228,7 @@ function readOrEmptyConfig(configPath: string): Partial<SandboxConfig> {
219
228
  }
220
229
  }
221
230
 
222
- function writeConfigFile(configPath: string, config: Partial<SandboxConfig>): void {
231
+ function writeConfigFile(configPath: string, config: SandboxConfigFile): void {
223
232
  mkdirSync(dirname(configPath), { recursive: true });
224
233
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
225
234
  }
@@ -253,7 +262,7 @@ async function addDomainToConfig(configPath: string, domain: string): Promise<vo
253
262
  ...config.network,
254
263
  allowedDomains: [...existing, domain],
255
264
  deniedDomains: config.network?.deniedDomains ?? [],
256
- } as SandboxNetworkConfig;
265
+ };
257
266
  writeConfigFile(configPath, config);
258
267
  });
259
268
  }
@@ -267,7 +276,7 @@ async function addReadPathToConfig(configPath: string, pathToAdd: string): Promi
267
276
  config.filesystem = {
268
277
  ...config.filesystem,
269
278
  allowRead: [...existing, pathToAdd],
270
- } as SandboxFilesystemConfig;
279
+ };
271
280
  writeConfigFile(configPath, config);
272
281
  });
273
282
  }
@@ -281,7 +290,7 @@ async function addWritePathToConfig(configPath: string, pathToAdd: string): Prom
281
290
  config.filesystem = {
282
291
  ...config.filesystem,
283
292
  allowWrite: [...existing, pathToAdd],
284
- } as SandboxFilesystemConfig;
293
+ };
285
294
  writeConfigFile(configPath, config);
286
295
  });
287
296
  }
@@ -362,7 +371,9 @@ function matchesPattern(filePath: string, patterns: string[]): boolean {
362
371
  const absPattern = pattern.includes('*') ? expandPath(pattern) : canonicalizePath(pattern);
363
372
 
364
373
  if (pattern.includes('*')) {
365
- const escaped = absPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
374
+ const escaped = absPattern
375
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
376
+ .replace(/\*\*\/|\*\*|\*/g, (token) => (token === '**/' ? '(?:.*/)?' : '.*'));
366
377
  return new RegExp(`^${escaped}$`).test(abs);
367
378
  }
368
379
 
@@ -438,11 +449,6 @@ function extractNativeWriteDeniedPath(output: string, cwd: string): string | nul
438
449
  );
439
450
  if (match) return normalizePathMatch(match[1], cwd);
440
451
 
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
452
  match = output.match(
447
453
  /^[a-zA-Z0-9_-]+: cannot create(?: directory)? '?([^'\n]+?)'?(?: for writing)?: (?:Operation not permitted|Permission denied)$/m,
448
454
  );
@@ -580,6 +586,25 @@ function setTuiStatus(ctx: ExtensionContext, key: string, value: string | undefi
580
586
  ctx.ui.setStatus(key, value);
581
587
  }
582
588
 
589
+ function boxTop(theme: Theme, width: number, title: string): string {
590
+ const label = theme.fg('accent', ` ${title} `);
591
+ const fill = theme.fg('border', '─'.repeat(Math.max(0, width - 4 - visibleWidth(label))));
592
+ return `${theme.fg('border', '╭─')}${label}${fill}${theme.fg('border', '─╮')}`;
593
+ }
594
+
595
+ function boxRow(theme: Theme, width: number, content = ''): string {
596
+ const innerW = Math.max(1, width - 4);
597
+ const border = theme.fg('border', '│');
598
+ const line = truncateToWidth(content, innerW);
599
+ const pad = Math.max(0, innerW - visibleWidth(line));
600
+ return `${border} ${line}${' '.repeat(pad)} ${border}`;
601
+ }
602
+
603
+ function boxBottom(theme: Theme, width: number): string {
604
+ const border = (s: string) => theme.fg('border', s);
605
+ return `${border('╰')}${border('─'.repeat(Math.max(0, width - 2)))}${border('╯')}`;
606
+ }
607
+
583
608
  async function showPermissionPrompt(
584
609
  ctx: ExtensionContext,
585
610
  title: string,
@@ -598,28 +623,14 @@ async function showPermissionPrompt(
598
623
 
599
624
  return {
600
625
  render(width: number): string[] {
601
- const innerW = width - 4;
626
+ const innerW = Math.max(1, width - 4);
602
627
  const lines: string[] = [];
603
- const border = theme.fg('border', '│');
604
628
  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
629
 
614
- // Blank spacing
615
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
616
-
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}`);
630
+ lines.push(boxTop(theme, width, 'Sandbox'));
631
+ lines.push(boxRow(theme, width));
632
+ lines.push(boxRow(theme, width, theme.fg('warning', title)));
633
+ lines.push(boxRow(theme, width));
623
634
 
624
635
  // Options
625
636
  for (let i = 0; i < options.length; i++) {
@@ -629,11 +640,11 @@ async function showPermissionPrompt(
629
640
 
630
641
  // Section divider before the permanent options (index 2 and 3)
631
642
  if (i === 2) {
632
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
643
+ lines.push(boxRow(theme, width));
633
644
  const secLabel = ' Permanent ';
634
645
  const secDash = '─'.repeat(Math.max(0, innerW - visibleWidth(secLabel)));
635
- lines.push(`${border} ${dim(secDash + secLabel)} ${border}`);
636
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
646
+ lines.push(boxRow(theme, width, dim(secDash + secLabel)));
647
+ lines.push(boxRow(theme, width));
637
648
  }
638
649
 
639
650
  // Key badge
@@ -668,25 +679,16 @@ async function showPermissionPrompt(
668
679
  }
669
680
 
670
681
  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}`);
682
+ lines.push(boxRow(theme, width, fullLine));
674
683
  }
675
684
 
676
685
  // Footer
677
- lines.push(`${border} ${' '.repeat(innerW)} ${border}`);
686
+ lines.push(boxRow(theme, width));
678
687
  const footerText = pendingAction
679
688
  ? '↑↓ navigate enter confirm esc cancel'
680
689
  : '↑↓ 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}`);
690
+ lines.push(boxRow(theme, width, dim(footerText)));
691
+ lines.push(boxBottom(theme, width));
690
692
 
691
693
  return lines;
692
694
  },
@@ -821,21 +823,25 @@ function proxyEnv(env: NodeJS.ProcessEnv | undefined, port: number): NodeJS.Proc
821
823
  };
822
824
  }
823
825
 
826
+ function parseProxyPort(value: string | undefined, defaultPort: number): number | null {
827
+ const rawPort = value ?? String(defaultPort);
828
+ if (!/^\d+$/.test(rawPort)) return null;
829
+
830
+ const port = Number(rawPort);
831
+ return port >= 1 && port <= 65535 ? port : null;
832
+ }
833
+
824
834
  function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
825
- const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
835
+ const bracketMatch = target.match(/^\[([^\]]+)\](?::(.*))?$/);
826
836
  if (bracketMatch) {
827
- return {
828
- host: bracketMatch[1],
829
- port: bracketMatch[2] ? Number(bracketMatch[2]) : defaultPort,
830
- };
837
+ const port = parseProxyPort(bracketMatch[2], defaultPort);
838
+ return port === null ? null : { host: bracketMatch[1], port };
831
839
  }
832
840
 
833
841
  const lastColon = target.lastIndexOf(':');
834
842
  if (lastColon > -1 && target.indexOf(':') === lastColon) {
835
- return {
836
- host: target.slice(0, lastColon),
837
- port: Number(target.slice(lastColon + 1)),
838
- };
843
+ const port = parseProxyPort(target.slice(lastColon + 1), defaultPort);
844
+ return port === null ? null : { host: target.slice(0, lastColon), port };
839
845
  }
840
846
 
841
847
  return { host: target, port: defaultPort };
@@ -858,20 +864,28 @@ function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): vo
858
864
 
859
865
  type LandstripBashTool = ReturnType<typeof createBashToolDefinition>;
860
866
 
867
+ /** Options for creating a landstrip sandbox integration. */
861
868
  export interface LandstripIntegrationOptions {
869
+ /** Register a sandboxed bash tool when the integration is registered. */
862
870
  registerBashTool?: boolean;
871
+ /** Working directory used when registering the default bash tool. */
863
872
  cwd?: string;
864
873
  }
865
874
 
875
+ /** Landstrip sandbox integration hooks for Pi. */
866
876
  export interface LandstripIntegration {
877
+ /** Create a bash tool definition that runs commands through landstrip when enabled. */
867
878
  createBashTool(cwd: string, ctx?: ExtensionContext): LandstripBashTool;
879
+ /** Register the integration's tools, events, flags, and commands with Pi. */
868
880
  register(pi: ExtensionAPI): void;
869
881
  }
870
882
 
883
+ /** Register the landstrip extension with Pi. */
871
884
  export default function (pi: ExtensionAPI) {
872
885
  createLandstripIntegration().register(pi);
873
886
  }
874
887
 
888
+ /** Create a landstrip integration for registration or custom embedding. */
875
889
  export function createLandstripIntegration(
876
890
  options: LandstripIntegrationOptions = {},
877
891
  ): LandstripIntegration {
@@ -896,18 +910,15 @@ export function createLandstripIntegration(
896
910
  sessionAllowedWritePaths.length = 0;
897
911
  }
898
912
 
899
- function getEffectiveAllowedDomains(cwd: string): string[] {
900
- const config = loadConfig(cwd);
913
+ function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
901
914
  return [...config.network.allowedDomains, ...sessionAllowedDomains];
902
915
  }
903
916
 
904
- function getEffectiveAllowRead(cwd: string): string[] {
905
- const config = loadConfig(cwd);
917
+ function getEffectiveAllowRead(config: SandboxConfig): string[] {
906
918
  return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
907
919
  }
908
920
 
909
- function getEffectiveAllowWrite(cwd: string): string[] {
910
- const config = loadConfig(cwd);
921
+ function getEffectiveAllowWrite(config: SandboxConfig): string[] {
911
922
  return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
912
923
  }
913
924
 
@@ -952,7 +963,7 @@ export function createLandstripIntegration(
952
963
  const config = loadConfig(cwd);
953
964
 
954
965
  if (domainMatchesAny(domain, config.network.deniedDomains)) return false;
955
- if (domainMatchesAny(domain, getEffectiveAllowedDomains(cwd))) return true;
966
+ if (domainMatchesAny(domain, getEffectiveAllowedDomains(config))) return true;
956
967
 
957
968
  const choice = await promptDomainBlock(ctx, domain);
958
969
  if (choice === 'abort') return false;
@@ -974,8 +985,8 @@ export function createLandstripIntegration(
974
985
  },
975
986
  filesystem: {
976
987
  denyRead: config.filesystem.denyRead,
977
- allowRead: getEffectiveAllowRead(cwd),
978
- allowWrite: getEffectiveAllowWrite(cwd),
988
+ allowRead: getEffectiveAllowRead(config),
989
+ allowWrite: getEffectiveAllowWrite(config),
979
990
  denyWrite: config.filesystem.denyWrite,
980
991
  },
981
992
  };
@@ -1045,7 +1056,13 @@ export function createLandstripIntegration(
1045
1056
  denyProxyRequest(client, '400 Bad Request');
1046
1057
  return;
1047
1058
  }
1048
- url = new URL(`http://${host}${rawTarget}`);
1059
+
1060
+ try {
1061
+ url = new URL(`http://${host}${rawTarget}`);
1062
+ } catch {
1063
+ denyProxyRequest(client, '400 Bad Request');
1064
+ return;
1065
+ }
1049
1066
  }
1050
1067
 
1051
1068
  if (!(await ensureDomainAllowed(ctx, url.hostname, cwd))) {
@@ -1053,7 +1070,12 @@ export function createLandstripIntegration(
1053
1070
  return;
1054
1071
  }
1055
1072
 
1056
- const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
1073
+ const defaultPort = url.protocol === 'https:' ? 443 : 80;
1074
+ const port = parseProxyPort(url.port || undefined, defaultPort);
1075
+ if (port === null) {
1076
+ denyProxyRequest(client, '400 Bad Request');
1077
+ return;
1078
+ }
1057
1079
  const path = `${url.pathname}${url.search}` || '/';
1058
1080
  lines[0] = `${method} ${path} ${version}`;
1059
1081
 
@@ -1219,7 +1241,6 @@ export function createLandstripIntegration(
1219
1241
  }
1220
1242
 
1221
1243
  signal?.addEventListener('abort', onAbort, { once: true });
1222
- const resolvedQueryIds = new Set<number>();
1223
1244
  let stderrAcc = '';
1224
1245
  let errorFdAcc = '';
1225
1246
 
@@ -1232,109 +1253,70 @@ export function createLandstripIntegration(
1232
1253
  trapSocket.on('data', (data: Buffer) => {
1233
1254
  errorFdAcc += data.toString('utf8');
1234
1255
  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
1256
  });
1257
1257
 
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
1258
  child.on('error', (error) => {
1282
1259
  cleanup();
1283
1260
  reject(error);
1284
1261
  });
1285
1262
 
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
- );
1263
+ child.on('close', (code) => {
1264
+ void (async () => {
1265
+ cleanup();
1266
+ if (signal?.aborted) {
1267
+ reject(new Error('aborted'));
1268
+ return;
1269
+ }
1270
+ if (timedOut) {
1271
+ reject(new Error(`timeout:${timeout}`));
1272
+ return;
1273
+ }
1314
1274
 
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,
1275
+ const errorOutput = errorFdAcc || stderrAcc;
1276
+
1277
+ const blockedPath =
1278
+ extractBlockedPath(errorOutput, cwd) ??
1279
+ (errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
1280
+ const blockedWritePath =
1281
+ extractBlockedWritePath(errorOutput, cwd) ??
1282
+ (errorFdAcc ? extractBlockedWritePath(stderrAcc, cwd) : null);
1283
+ if (blockedPath && ctx.hasUI && callbacks.promptOnBlock) {
1284
+ const config = loadConfig(cwd);
1285
+ const isDeniedByDenyRead = matchesPattern(
1321
1286
  blockedPath,
1322
- isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
1287
+ config.filesystem.denyRead,
1323
1288
  );
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');
1289
+ const isReadAllowed = matchesPattern(blockedPath, getEffectiveAllowRead(config));
1290
+ const isWriteAllowed = !shouldPromptForWrite(
1291
+ blockedPath,
1292
+ getEffectiveAllowWrite(config),
1293
+ matchesPattern,
1294
+ );
1295
+
1296
+ if (blockedWritePath === blockedPath && !isWriteAllowed) {
1297
+ const choice = await promptWriteBlock(ctx, blockedPath);
1298
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1299
+ } else if (isDeniedByDenyRead || !isReadAllowed) {
1300
+ const choice = await promptReadBlock(
1301
+ ctx,
1302
+ blockedPath,
1303
+ isDeniedByDenyRead ? 'granting allowRead will override it' : undefined,
1304
+ );
1305
+ if (choice !== 'abort') await applyReadChoice(choice, blockedPath, cwd);
1306
+ } else if (!isWriteAllowed) {
1307
+ const choice = await promptWriteBlock(ctx, blockedPath);
1308
+ if (choice !== 'abort') await applyWriteChoice(choice, blockedPath, cwd);
1309
+ }
1310
+ } else if (!blockedPath && ctx.hasUI) {
1311
+ const landstripErrors = parseLandstripTraps(errorOutput);
1312
+ if (landstripErrors.length > 0) {
1313
+ const formatted = formatLandstripTraps(landstripErrors);
1314
+ notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
1315
+ }
1334
1316
  }
1335
- }
1336
1317
 
1337
- resolvePromise({ exitCode: code });
1318
+ resolvePromise({ exitCode: code });
1319
+ })().catch(reject);
1338
1320
  });
1339
1321
  })().catch(reject);
1340
1322
  });
@@ -1380,7 +1362,7 @@ export function createLandstripIntegration(
1380
1362
  return null;
1381
1363
  }
1382
1364
 
1383
- if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(ctx.cwd), matchesPattern)) {
1365
+ if (shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config), matchesPattern)) {
1384
1366
  const choice = await promptWriteBlock(ctx, blockedPath);
1385
1367
  if (choice === 'abort') return null;
1386
1368
  await applyWriteChoice(choice, blockedPath, ctx.cwd);
@@ -1412,8 +1394,8 @@ export function createLandstripIntegration(
1412
1394
  ): Promise<AgentToolResult<BashToolDetails | undefined> | null> => {
1413
1395
  if (!ctx.hasUI) return null;
1414
1396
 
1415
- if (!matchesPattern(blockedPath, getEffectiveAllowRead(ctx.cwd))) {
1416
- const config = loadConfig(ctx.cwd);
1397
+ const config = loadConfig(ctx.cwd);
1398
+ if (!matchesPattern(blockedPath, getEffectiveAllowRead(config))) {
1417
1399
  const choice = await promptReadBlock(
1418
1400
  ctx,
1419
1401
  blockedPath,
@@ -1648,7 +1630,7 @@ export function createLandstripIntegration(
1648
1630
  }
1649
1631
  }
1650
1632
 
1651
- return { operations: createLandstripBashOps(ctx) };
1633
+ return { operations: createLandstripBashOps(ctx, { promptOnBlock: true }) };
1652
1634
  });
1653
1635
 
1654
1636
  pi.on('tool_call', async (event, ctx) => {
@@ -1672,7 +1654,7 @@ export function createLandstripIntegration(
1672
1654
 
1673
1655
  if (isToolCallEventType('read', event)) {
1674
1656
  const filePath = canonicalizePath(event.input.path);
1675
- if (!matchesPattern(filePath, getEffectiveAllowRead(ctx.cwd))) {
1657
+ if (!matchesPattern(filePath, getEffectiveAllowRead(config))) {
1676
1658
  const choice = await promptReadBlock(ctx, filePath);
1677
1659
  if (choice === 'abort') {
1678
1660
  return {
@@ -1696,7 +1678,7 @@ export function createLandstripIntegration(
1696
1678
  };
1697
1679
  }
1698
1680
 
1699
- if (shouldPromptForWrite(filePath, getEffectiveAllowWrite(ctx.cwd), matchesPattern)) {
1681
+ if (shouldPromptForWrite(filePath, getEffectiveAllowWrite(config), matchesPattern)) {
1700
1682
  const choice = await promptWriteBlock(ctx, filePath);
1701
1683
  if (choice === 'abort') {
1702
1684
  return {
@@ -1742,7 +1724,6 @@ export function createLandstripIntegration(
1742
1724
  const muted = (s: string) => theme.fg('muted', s);
1743
1725
  const accent = (s: string) => theme.fg('accent', s);
1744
1726
  const text = (s: string) => theme.fg('text', s);
1745
- const borderFg = (s: string) => theme.fg('border', s);
1746
1727
 
1747
1728
  function sandboxStatus(): { color: 'success' | 'warning'; label: string } {
1748
1729
  if (noSandboxFlag) return { color: 'warning', label: 'Disabled (--no-sandbox)' };
@@ -1755,29 +1736,16 @@ export function createLandstripIntegration(
1755
1736
  return v ? theme.fg('warning', 'yes') : theme.fg('success', 'no');
1756
1737
  }
1757
1738
 
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
1739
  return {
1765
1740
  render(width: number): string[] {
1766
1741
  const innerW = Math.max(1, width - 4);
1767
- const border = borderFg('');
1768
- const row = (content: string) => makeRow(content, innerW, border);
1742
+ const row = (content = '') => boxRow(theme, width, content);
1769
1743
  const lines: string[] = [];
1770
1744
  const status = sandboxStatus();
1771
1745
  const toggleValue = config.enabled
1772
1746
  ? theme.fg('success', 'enabled')
1773
1747
  : theme.fg('warning', 'disabled');
1774
1748
 
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
1749
  function section(titleText: string, detail?: string): void {
1782
1750
  lines.push(row(''));
1783
1751
  lines.push(row(`${accent(titleText)}${detail ? dim(` · ${detail}`) : ''}`));
@@ -1792,7 +1760,7 @@ export function createLandstripIntegration(
1792
1760
  return text(truncateToWidth(value, Math.max(10, maxWidth)));
1793
1761
  }
1794
1762
 
1795
- lines.push(topBorder('Sandbox'));
1763
+ lines.push(boxTop(theme, width, 'Sandbox'));
1796
1764
 
1797
1765
  const statusDot = theme.fg(status.color, '●');
1798
1766
  const pathSnippet = text(truncateToWidth(binaryPath(), Math.max(20, innerW - 28)));
@@ -1834,9 +1802,7 @@ export function createLandstripIntegration(
1834
1802
  `${dim('t')} ${muted('toggle persisted setting')} ${dim('esc')} ${muted('close')}`,
1835
1803
  ),
1836
1804
  );
1837
- lines.push(
1838
- `${borderFg('╰')}${borderFg('─'.repeat(Math.max(0, width - 2)))}${borderFg('╯')}`,
1839
- );
1805
+ lines.push(boxBottom(theme, width));
1840
1806
 
1841
1807
  return lines;
1842
1808
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.15.6",
3
+ "version": "0.15.8",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",