auto-permission-system 3.9.1 → 3.12.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.
@@ -91,7 +91,22 @@ async function detectMcpAsk(
91
91
  policy: mcpPolicy,
92
92
  });
93
93
  if (result.decision !== "ask") return null;
94
- return askDetection(ctx, "tool", toolName, result.summary);
94
+
95
+ // Include detailed call parameters or script preview if present
96
+ let detailedSummary = result.summary;
97
+ if (input && Object.keys(input).length > 0) {
98
+ const rawScript = input.code ?? input.script ?? input.command ?? input.query;
99
+ if (typeof rawScript === "string" && rawScript.includes("\n")) {
100
+ detailedSummary += `\n\n${rawScript}`;
101
+ } else {
102
+ const formattedArgs = JSON.stringify(input, null, 2);
103
+ if (formattedArgs.includes("\n")) {
104
+ detailedSummary += `\n\nArguments:\n${formattedArgs}`;
105
+ }
106
+ }
107
+ }
108
+
109
+ return askDetection(ctx, "tool", toolName, detailedSummary);
95
110
  }
96
111
 
97
112
  export default function (pi: ExtensionAPI) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-permission-system",
3
- "version": "3.9.1",
3
+ "version": "3.12.0",
4
4
  "description": "Claude auto mode for Pi & security for Pi. A zero-trust permission system for Pi coding agent powered by Advanced Secure mode to scan files, content, and tools to prevent data breaches.",
5
5
  "type": "module",
6
6
  "author": "Navin Kumar",
@@ -2,6 +2,7 @@
2
2
  "enabled": true,
3
3
  "mode": "advanced-secure",
4
4
  "network": {
5
+ "allowLocalhost": false,
5
6
  "strictAllowlist": true,
6
7
  "allowedDomains": [
7
8
  "registry.npmjs.org",
@@ -2,6 +2,7 @@
2
2
  "enabled": true,
3
3
  "mode": "advanced-secure",
4
4
  "network": {
5
+ "allowLocalhost": true,
5
6
  "allowedDomains": [
6
7
  "registry.npmjs.org",
7
8
  "registry.yarnpkg.com",
@@ -24,13 +25,22 @@
24
25
  "rubygems.org",
25
26
  "api.nuget.org",
26
27
  "deno.land",
27
- "jsr.io"
28
+ "jsr.io",
29
+ "api.openai.com",
30
+ "huggingface.co",
31
+ "*.huggingface.co",
32
+ "cdn-lfs.huggingface.co",
33
+ "*.diffusion.studio",
34
+ "diffusion.studio"
28
35
  ]
29
36
  },
30
37
  "filesystem": {
31
38
  "outsideProject": {
32
39
  "read": "allow",
33
- "allowRead": []
40
+ "allowRead": [
41
+ "~/Downloads",
42
+ "~/.cache"
43
+ ]
34
44
  },
35
45
  "denyRead": [],
36
46
  "askRead": [
@@ -39,6 +39,7 @@ A higher layer only states what it changes; absent keys keep the layer below. A
39
39
  "enabled": true,
40
40
  "mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
41
41
  "network": {
42
+ "allowLocalhost": true, // true (default): localhost on all ports allowed; false: strict network isolation
42
43
  "allowedDomains": ["github.com", "*.github.com"], // empty = allow all
43
44
  "deniedDomains": [], // hard deny, never ask-able
44
45
  "strictAllowlist": false // true = deny unknown hosts instead of asking
@@ -31,6 +31,63 @@ export function isWriteBlockError(output: string): boolean {
31
31
  return WRITE_BLOCK_RE.test(output);
32
32
  }
33
33
 
34
+ /**
35
+ * Loopback / localhost connection refusal evidence in a sandboxed command's output.
36
+ * On Linux, bubblewrap runs with --unshare-net which isolates network namespaces.
37
+ * Host services listening on 127.0.0.1/localhost (like Diffusion Studio on port 3274,
38
+ * or local dev servers) are not reachable from inside the container namespace.
39
+ */
40
+ const LOOPBACK_REFUSAL_RE =
41
+ /(?:Failed to connect to (?:127\.0\.0\.1|localhost|::1)|(?:connect|connection refused).*?(?:127\.0\.0\.1|localhost|::1)|(?:127\.0\.0\.1|localhost|::1).*?(?:connection refused|ECONNREFUSED)|ECONNREFUSED.*?(?:127\.0\.0\.1|localhost|::1)|Diffusion Studio is not running)/i;
42
+
43
+ /** True when the command failed due to connecting to a loopback/localhost address in the sandbox. */
44
+ export function isLoopbackConnectionRefused(output: string): boolean {
45
+ return LOOPBACK_REFUSAL_RE.test(output);
46
+ }
47
+
48
+ export interface LoopbackEndpointInfo {
49
+ host: string;
50
+ port?: string;
51
+ service?: string;
52
+ }
53
+
54
+ /**
55
+ * Extract endpoint metadata (host, port, service name) from a loopback connection failure.
56
+ */
57
+ export function extractLoopbackEndpoint(output: string, command?: string): LoopbackEndpointInfo | undefined {
58
+ if (!isLoopbackConnectionRefused(output)) return undefined;
59
+
60
+ // Check for Diffusion Studio specific error or command
61
+ const isDiffusion = /diffusion studio is not running/i.test(output) || (command && /\b(?:dapi|diffusion)\b/i.test(command));
62
+ if (isDiffusion) {
63
+ return { host: "127.0.0.1", port: "3274", service: "Diffusion Studio" };
64
+ }
65
+
66
+ // Match "Failed to connect to 127.0.0.1 port 3274" or "localhost port 8080"
67
+ const curlMatch = output.match(/Failed to connect to (127\.0\.0\.1|localhost|::1) port (\d+)/i);
68
+ if (curlMatch) {
69
+ return {
70
+ host: curlMatch[1],
71
+ port: curlMatch[2],
72
+ service: curlMatch[2] === "3274" ? "Diffusion Studio" : undefined,
73
+ };
74
+ }
75
+
76
+ // Match "connect ECONNREFUSED 127.0.0.1:3274"
77
+ const nodeMatch = output.match(/(?:ECONNREFUSED|connect to)\s+(127\.0\.0\.1|localhost|::1)(?::| port )(\d+)/i);
78
+ if (nodeMatch) {
79
+ return {
80
+ host: nodeMatch[1],
81
+ port: nodeMatch[2],
82
+ service: nodeMatch[2] === "3274" ? "Diffusion Studio" : undefined,
83
+ };
84
+ }
85
+
86
+ // Generic host match
87
+ const hostMatch = output.match(/(127\.0\.0\.1|localhost|::1)/i);
88
+ return { host: hostMatch ? hostMatch[1] : "127.0.0.1" };
89
+ }
90
+
34
91
  /** A shell-name/empty token is not a path; everything else resolves against cwd unless absolute. */
35
92
  function toAbsolutePath(rawToken: string, cwd: string, home: string): string | undefined {
36
93
  let token = rawToken.trim().replace(/[.,;]+$/, "");
@@ -181,6 +181,33 @@ export const COMMON_NON_SECRET_VALUES = new Set([
181
181
  "secretvalue",
182
182
  "somevalue",
183
183
  "myvalue",
184
+ // Common keyboard keys / UI events (e.g. `Successfully pressed key: Backspace`)
185
+ "backspace",
186
+ "tab",
187
+ "enter",
188
+ "return",
189
+ "escape",
190
+ "esc",
191
+ "space",
192
+ "pageup",
193
+ "pagedown",
194
+ "end",
195
+ "home",
196
+ "arrowleft",
197
+ "arrowup",
198
+ "arrowright",
199
+ "arrowdown",
200
+ "left",
201
+ "up",
202
+ "right",
203
+ "down",
204
+ "delete",
205
+ "insert",
206
+ "capslock",
207
+ "numlock",
208
+ "scrolllock",
209
+ "printscreen",
210
+ "pause",
184
211
  ]);
185
212
 
186
213
  /**
@@ -9,11 +9,12 @@ import { homedir } from "node:os";
9
9
  import { dirname } from "node:path";
10
10
  import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
11
11
  import { type BashOperations } from "@earendil-works/pi-coding-agent";
12
- import { askReadCandidates, extractBlockedPath, isBlockedAccessError, isSafeFolderGrant, isWriteBlockError, matchedAskCommands, matchesPolicyPattern, outsideProjectMode, outsideProjectReadCandidates, outsideProjectReadDenied, sandboxFilesystem } from "../core/index";
12
+ import { askReadCandidates, extractBlockedPath, extractLoopbackEndpoint, isBlockedAccessError, isLoopbackConnectionRefused, isSafeFolderGrant, isWriteBlockError, matchedAskCommands, matchesPolicyPattern, outsideProjectMode, outsideProjectReadCandidates, outsideProjectReadDenied, sandboxFilesystem } from "../core/index";
13
13
  import { audit } from "../shared/audit";
14
14
  import { canonicalize } from "../shared/canonicalize";
15
15
  import { askMain, askMainWithRemember, askRememberFile, MainChoice, type AskCtx } from "../ui/ask-flow";
16
16
  import { loadConfig, projectTrusted } from "./config";
17
+ import { injectBridgesIntoBwrap, startHostPortBridges, type HostPortBridge } from "./host-bridge";
17
18
 
18
19
  /** First whitespace-separated token of a bash command — used to name the binary in prompt titles. */
19
20
  function firstWord(command: string): string {
@@ -76,11 +77,15 @@ async function preflightSensitiveCommands(
76
77
  const matched = matchedAskCommands(command, ask, cwd, homedir());
77
78
  if (!matched.length) return undefined;
78
79
  const ui = opts.ctx as AskCtx;
79
- const body = [
80
+ const bodyLines: string[] = [
80
81
  `Command ${matched.join(", ")}`,
81
82
  `Risk environment variables often hold API keys and tokens`,
82
83
  `Note can only be allowed one run at a time`,
83
- ].join("\n");
84
+ ];
85
+ if (command.includes("\n")) {
86
+ bodyLines.push(`\nScript:\n${command}`);
87
+ }
88
+ const body = bodyLines.join("\n");
84
89
  const choice = await askMain(ui, `Let ${matched[0]} print your environment variables?`, body, { once: true, remember: false, icon: "cred" });
85
90
  const subject = matched.join(", ");
86
91
  if (choice === "once") {
@@ -215,7 +220,26 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
215
220
  const grantedWriteFolders = new Set<string>();
216
221
  const grantedWriteFiles = new Set<string>();
217
222
  const attempt = async (writeOnce: string[]): Promise<{ exitCode: number | null }> => {
218
- const wrappedCommand = await SandboxManager.wrapWithSandbox(command, undefined, buildCustom(writeOnce));
223
+ const cfg = loadConfig(cwd);
224
+ const allowLocalhost = cfg.allowLocalhost ?? cfg.network?.allowLocalhost ?? true;
225
+ let bridges: HostPortBridge[] = [];
226
+ if (!allowLocalhost && process.platform === "linux") {
227
+ const hostPorts = cfg.bridgeHostPorts ?? [3274];
228
+ bridges = await startHostPortBridges(hostPorts);
229
+ }
230
+ const rawWrapped = await SandboxManager.wrapWithSandbox(command, undefined, buildCustom(writeOnce));
231
+ let wrappedCommand = rawWrapped;
232
+ if (process.platform === "linux") {
233
+ if (allowLocalhost) {
234
+ wrappedCommand = wrappedCommand.replace(" --unshare-net ", " ");
235
+ } else if (bridges.length > 0) {
236
+ wrappedCommand = injectBridgesIntoBwrap(wrappedCommand, bridges);
237
+ }
238
+ }
239
+
240
+ const cleanupBridges = () => {
241
+ bridges.forEach((b) => b.close());
242
+ };
219
243
 
220
244
  return new Promise((resolve, reject) => {
221
245
  const child = spawn("bash", ["-c", wrappedCommand], {
@@ -251,11 +275,13 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
251
275
 
252
276
  child.on("error", (err) => {
253
277
  if (timeoutHandle) clearTimeout(timeoutHandle);
278
+ cleanupBridges();
254
279
  SandboxManager.cleanupAfterCommand();
255
280
  reject(err);
256
281
  });
257
282
 
258
283
  const onAbort = () => {
284
+ cleanupBridges();
259
285
  if (child.pid) {
260
286
  try {
261
287
  process.kill(-child.pid, "SIGKILL");
@@ -270,6 +296,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
270
296
  child.on("close", async (code) => {
271
297
  if (timeoutHandle) clearTimeout(timeoutHandle);
272
298
  signal?.removeEventListener("abort", onAbort);
299
+ cleanupBridges();
273
300
 
274
301
  SandboxManager.cleanupAfterCommand();
275
302
 
@@ -326,6 +353,50 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
326
353
  ` or write inside the project directory (${cwd}).\n`;
327
354
  }
328
355
  blockedHint += ` Policy: ~/.pi/agent/extensions/sandbox.json (+ <cwd>/.pi/sandbox.json overrides).\n`;
356
+ } else if (code !== 0 && isLoopbackConnectionRefused(outputTail)) {
357
+ const endpointInfo = extractLoopbackEndpoint(outputTail, command);
358
+ const endpointStr = endpointInfo?.port ? `${endpointInfo.host}:${endpointInfo.port}` : (endpointInfo?.host ?? "127.0.0.1");
359
+ const serviceName = endpointInfo?.service;
360
+
361
+ auditL1({
362
+ subject: endpointStr,
363
+ decision: "loopback-refused",
364
+ reason: "Linux sandbox network namespace (--unshare-net) isolates host loopback",
365
+ cwd: opts?.ctx?.cwd,
366
+ note: serviceName ? `service:${serviceName}` : undefined,
367
+ });
368
+
369
+ const notifyMsg = serviceName === "Diffusion Studio"
370
+ ? "pi-sandbox: Diffusion Studio is running on host port 3274, but sandboxed bash cannot reach host localhost. Connect it via MCP in ~/.pi/agent/mcp.json."
371
+ : `pi-sandbox: connection to host ${endpointStr} refused. Sandboxed bash cannot reach host localhost.`;
372
+ opts?.ctx?.ui?.notify?.(notifyMsg, "warning");
373
+
374
+ blockedHint = `\n💡 pi-sandbox: localhost / loopback connection refused.\n` +
375
+ ` Target: ${endpointStr}${serviceName ? ` (${serviceName})` : ""}\n` +
376
+ ` Why: The Linux sandbox runs inside an isolated network namespace (--unshare-net).\n` +
377
+ ` Services running on your HOST's 127.0.0.1/localhost are isolated and cannot\n` +
378
+ ` be reached directly from sandboxed bash commands.\n`;
379
+
380
+ if (serviceName === "Diffusion Studio" || /dapi|diffusion|3274/i.test(command)) {
381
+ blockedHint +=
382
+ ` Resolution for Diffusion Studio:\n` +
383
+ ` • Diffusion Studio exposes an MCP server on http://127.0.0.1:3274/mcp\n` +
384
+ ` • Add it to ~/.pi/agent/mcp.json so Pi connects directly on the host:\n` +
385
+ ` {\n` +
386
+ ` "mcpServers": {\n` +
387
+ ` "diffusion-studio": { "url": "http://127.0.0.1:3274/mcp" }\n` +
388
+ ` }\n` +
389
+ ` }\n` +
390
+ ` • Once added, restart or reload Pi: the agent gets native MCP tools\n` +
391
+ ` (context, check, capture, probe) with Layer 2 security, no bash needed.\n` +
392
+ ` • Or run pi with --yolo for one-off host access.\n`;
393
+ } else {
394
+ blockedHint +=
395
+ ` Resolution:\n` +
396
+ ` • If this is an MCP service, configure it in ~/.pi/agent/mcp.json.\n` +
397
+ ` • Pi runs on the host and can communicate with local MCP servers directly.\n` +
398
+ ` • Or run pi with --yolo if this task requires interacting with host network services.\n`;
399
+ }
329
400
  }
330
401
  /**
331
402
  * Emit the block explanation only when the command is not re-run with a
@@ -22,10 +22,18 @@ export interface SandboxFilesystem extends NonNullable<SandboxRuntimeConfig["fil
22
22
  outsideProject?: { read?: ReadPosture; allowRead?: string[] };
23
23
  }
24
24
 
25
- export interface SandboxConfig extends Omit<SandboxRuntimeConfig, "filesystem"> {
25
+ export interface SandboxNetwork extends Partial<NonNullable<SandboxRuntimeConfig["network"]>> {
26
+ /** Whether to allow localhost / loopback on all ports (default: true). Set false to strictly isolate network. */
27
+ allowLocalhost?: boolean;
28
+ /** Host TCP ports to bridge into the Linux sandbox namespace when allowLocalhost is false. */
29
+ bridgeHostPorts?: number[];
30
+ }
31
+
32
+ export interface SandboxConfig extends Omit<SandboxRuntimeConfig, "filesystem" | "network"> {
26
33
  enabled?: boolean;
27
34
  /** `default` = rules only; `advanced-secure` adds secret/credential detection (ADR-018). */
28
35
  mode?: PermissionMode;
36
+ network?: SandboxNetwork;
29
37
  filesystem?: SandboxFilesystem;
30
38
  /**
31
39
  * Additive project-local overrides written by the ask-tier prompts (Layer 1
@@ -40,6 +48,14 @@ export interface SandboxConfig extends Omit<SandboxRuntimeConfig, "filesystem">
40
48
  };
41
49
  /** Commands/paths that can print secrets (env, proc environ) and must ask first. */
42
50
  commands?: { ask?: string[] };
51
+ /** Allow all Unix domain sockets on Linux (disables seccomp socket blocking). */
52
+ allowAllUnixSockets?: boolean;
53
+ /** Allow local port binding. */
54
+ allowLocalBinding?: boolean;
55
+ /** Whether to allow localhost / loopback on all ports (default: true). Set false to strictly isolate network. */
56
+ allowLocalhost?: boolean;
57
+ /** Host TCP ports to bridge into the Linux sandbox namespace when allowLocalhost is false. */
58
+ bridgeHostPorts?: number[];
43
59
  }
44
60
 
45
61
  const BUILTIN_CONFIG: SandboxConfig = {
@@ -48,6 +64,7 @@ const BUILTIN_CONFIG: SandboxConfig = {
48
64
  network: {
49
65
  allowedDomains: [...BUILTIN_NETWORK_ALLOWED],
50
66
  deniedDomains: [],
67
+ allowLocalhost: true,
51
68
  },
52
69
  filesystem: {
53
70
  denyRead: [...DEFAULT_DENY_READ],
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Host loopback port bridge for Linux sandbox.
3
+ *
4
+ * Bubblewrap on Linux isolates the network namespace via `--unshare-net`.
5
+ * In that isolated namespace, loopback `127.0.0.1` is disconnected from the host,
6
+ * preventing tools (like Diffusion Studio CLI `dapi` or local dev servers) from
7
+ * reaching services running on the host's localhost (e.g. port 3274).
8
+ *
9
+ * This module creates a lightweight Unix domain socket bridge on the host
10
+ * (using Node's built-in `node:net`), bind-mounts the socket into bwrap,
11
+ * and starts a `socat` listener inside the container forwarding container
12
+ * `127.0.0.1:${port}` to the host service.
13
+ */
14
+
15
+ import { randomBytes } from "node:crypto";
16
+ import { existsSync, unlinkSync } from "node:fs";
17
+ import net from "node:net";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ export interface HostPortBridge {
22
+ port: number;
23
+ sockPath: string;
24
+ close: () => void;
25
+ }
26
+
27
+ /**
28
+ * Start a Unix domain socket bridge on the host forwarding to host 127.0.0.1:port.
29
+ */
30
+ export async function startHostPortBridge(port: number): Promise<HostPortBridge> {
31
+ const id = randomBytes(6).toString("hex");
32
+ const sockPath = join(tmpdir(), `pi-host-bridge-${port}-${id}.sock`);
33
+
34
+ const server = net.createServer((client) => {
35
+ const target = net.connect(port, "127.0.0.1");
36
+ client.pipe(target).pipe(client);
37
+ client.on("error", () => target.destroy());
38
+ target.on("error", () => client.destroy());
39
+ });
40
+
41
+ await new Promise<void>((resolve, reject) => {
42
+ server.listen(sockPath, () => resolve());
43
+ server.on("error", reject);
44
+ });
45
+
46
+ return {
47
+ port,
48
+ sockPath,
49
+ close: () => {
50
+ try {
51
+ server.close();
52
+ } catch (err) {
53
+ void err;
54
+ }
55
+ try {
56
+ if (existsSync(sockPath)) unlinkSync(sockPath);
57
+ } catch (err) {
58
+ void err;
59
+ }
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Start bridges for multiple ports concurrently.
66
+ */
67
+ export async function startHostPortBridges(ports: number[]): Promise<HostPortBridge[]> {
68
+ if (process.platform !== "linux" || !ports.length) return [];
69
+ return Promise.all(ports.map((p) => startHostPortBridge(p)));
70
+ }
71
+
72
+ /**
73
+ * Inject the bridges into the bwrap command invocation on Linux.
74
+ */
75
+ export function injectBridgesIntoBwrap(wrappedCommand: string, bridges: HostPortBridge[]): string {
76
+ if (process.platform !== "linux" || !bridges.length) return wrappedCommand;
77
+
78
+ const dashIndex = wrappedCommand.indexOf(" -- ");
79
+ if (dashIndex === -1) return wrappedCommand;
80
+
81
+ // 1. Add --bind for each socket before " -- "
82
+ const binds = bridges.map((b) => `--bind ${b.sockPath} ${b.sockPath}`).join(" ");
83
+ const bwrapPart = `${wrappedCommand.slice(0, dashIndex)} ${binds}`;
84
+ let shellPart = wrappedCommand.slice(dashIndex + 4);
85
+
86
+ // 2. Start socat listeners inside the container
87
+ const socatStarters = bridges
88
+ .map((b) => `socat TCP-LISTEN:${b.port},fork,reuseaddr UNIX-CONNECT:${b.sockPath} >/dev/null 2>&1 &\n_pi_bridge_pids="$_pi_bridge_pids $!"`)
89
+ .join("\n");
90
+
91
+ if (shellPart.includes("socat TCP-LISTEN:3128")) {
92
+ shellPart = shellPart.replace("socat TCP-LISTEN:3128", `${socatStarters}\nsocat TCP-LISTEN:3128`);
93
+ // Include our bridge pids in the script EXIT trap cleanup
94
+ shellPart = shellPart.replace("kill %1 %2 2>/dev/null;", "kill %1 %2 $_pi_bridge_pids 2>/dev/null;");
95
+ return `${bwrapPart} -- ${shellPart}`;
96
+ }
97
+
98
+ return wrappedCommand;
99
+ }
@@ -36,11 +36,13 @@ const SUPPRESSED_PROJECT_FILES = new Set([
36
36
  */
37
37
  export function suppressGhostDotfiles(): void {
38
38
  try {
39
+ /* SAFETY: DANGEROUS_FILES is an array of strings in sandbox-runtime. */
39
40
  const files = DANGEROUS_FILES as unknown as string[];
40
41
  const filteredFiles = files.filter((f) => !SUPPRESSED_PROJECT_FILES.has(f));
41
42
  files.length = 0;
42
43
  files.push(...filteredFiles);
43
44
 
45
+ /* SAFETY: DANGEROUS_DIRECTORIES is an array of strings in sandbox-runtime. */
44
46
  const dirs = DANGEROUS_DIRECTORIES as unknown as string[];
45
47
  const filteredDirs = dirs.filter((d) => d === ".git");
46
48
  dirs.length = 0;
@@ -64,8 +66,17 @@ type Layer1FilesystemKind = typeof OverrideKind.AllowWrite | typeof OverrideKind
64
66
  * filesystem.outsideProject.read gates reads.
65
67
  */
66
68
  function runtimeConfig(cwd: string, config: SandboxConfig): SandboxRuntimeConfig {
69
+ const allowLocalhost = config.allowLocalhost ?? config.network?.allowLocalhost ?? true;
70
+ const network = config.network
71
+ ? {
72
+ ...config.network,
73
+ allowAllUnixSockets: config.allowAllUnixSockets ?? config.network.allowAllUnixSockets,
74
+ allowLocalBinding: config.allowLocalBinding ?? config.network.allowLocalBinding ?? allowLocalhost,
75
+ }
76
+ : undefined;
77
+
67
78
  return {
68
- network: config.network,
79
+ network: network as NonNullable<SandboxRuntimeConfig["network"]>,
69
80
  filesystem: config.filesystem
70
81
  ? sandboxFilesystem(config.filesystem, { cwd, home: homedir() })
71
82
  : { denyRead: [], allowRead: [], allowWrite: [], denyWrite: [], disabled: true },
@@ -87,7 +98,8 @@ export async function initSandbox(cwd: string, config: SandboxConfig, deps?: Net
87
98
  export async function resetSandbox(): Promise<void> {
88
99
  try {
89
100
  await SandboxManager.reset();
90
- } catch {
101
+ } catch (err) {
102
+ void err;
91
103
  }
92
104
  }
93
105
 
@@ -214,7 +214,17 @@ async function askWithSelect(
214
214
  timeoutMs: number,
215
215
  footer: string | null,
216
216
  ): Promise<{ picked: string | undefined; expired: boolean }> {
217
- const selectTitle = footer ? `${title}\n\n${footer}` : title;
217
+ // Guard against terminal overflow in non-custom/RPC mode for long scripts
218
+ let boundedTitle = title;
219
+ const lines = title.split("\n");
220
+ const MAX_FALLBACK_LINES = 16;
221
+ if (lines.length > MAX_FALLBACK_LINES) {
222
+ const header = lines[0];
223
+ const preview = lines.slice(1, MAX_FALLBACK_LINES).join("\n");
224
+ boundedTitle = `${header}\n${preview}\n\n[... ${lines.length - MAX_FALLBACK_LINES} more lines truncated]`;
225
+ }
226
+
227
+ const selectTitle = footer ? `${boundedTitle}\n\n${footer}` : boundedTitle;
218
228
  if (timeoutMs === 0) {
219
229
  const picked = await ctx.ui.select(selectTitle, options);
220
230
  return { picked, expired: false };
@@ -54,6 +54,8 @@ export type AskSelectorOptions = {
54
54
  onExpire: () => void;
55
55
  /** Countdown tick, default 1000 ms. Test seam. */
56
56
  tickMs?: number;
57
+ /** Maximum body lines shown in viewport before scrolling engages. Default 12. */
58
+ maxBodyLines?: number;
57
59
  };
58
60
 
59
61
  /** A one-line horizontal rule, like pi's DynamicBorder but without the
@@ -70,6 +72,9 @@ class Border implements Component {
70
72
  }
71
73
  }
72
74
 
75
+ const SCROLL_UP_KEYS = new Set(["\x1b[5~", "[", "\x1b[1;2A"]);
76
+ const SCROLL_DOWN_KEYS = new Set(["\x1b[6~", "]", "\x1b[1;2B"]);
77
+
73
78
  export class AskSelector extends Container {
74
79
  private readonly theme: AskTheme;
75
80
  private readonly tui: { requestRender(): void };
@@ -90,6 +95,14 @@ export class AskSelector extends Container {
90
95
  private finished = false;
91
96
  private engaged = false;
92
97
 
98
+ private readonly isMultiLine: boolean;
99
+ private readonly headerText: string;
100
+ private readonly bodyLines: string[];
101
+ private readonly maxBodyLines: number;
102
+ private readonly bodyComponent: Text | null = null;
103
+ private readonly indicatorComponent: Text | null = null;
104
+ private bodyScrollOffset = 0;
105
+
93
106
  constructor(o: AskSelectorOptions) {
94
107
  super();
95
108
  this.theme = o.theme;
@@ -103,10 +116,35 @@ export class AskSelector extends Container {
103
116
  this.labels = o.options;
104
117
  this.footerVisible = o.footer !== null && o.footer !== "";
105
118
  this.remaining = Math.ceil(o.timeoutMs / 1000);
119
+ this.maxBodyLines = o.maxBodyLines ?? 12;
120
+
121
+ const allLines = o.title.split("\n");
122
+ this.isMultiLine = allLines.length > 1;
123
+
124
+ if (this.isMultiLine) {
125
+ this.headerText = allLines[0] ?? "";
126
+ this.bodyLines = allLines.slice(1);
127
+ } else {
128
+ this.headerText = o.title;
129
+ this.bodyLines = [];
130
+ }
106
131
 
107
132
  this.addChild(new Border((t) => this.theme.fg("border", t)));
108
133
  this.addChild(new Spacer(1));
109
- this.addChild(new Text(this.theme.fg("accent", this.theme.bold(o.title)), 0, 0));
134
+
135
+ if (!this.isMultiLine) {
136
+ this.addChild(new Text(this.theme.fg("accent", this.theme.bold(o.title)), 0, 0));
137
+ } else if (this.bodyLines.length <= this.maxBodyLines) {
138
+ this.addChild(new Text(this.theme.fg("accent", this.theme.bold(o.title)), 0, 0));
139
+ } else {
140
+ this.addChild(new Text(this.theme.fg("accent", this.theme.bold(this.headerText)), 0, 0));
141
+ this.addChild(new Spacer(1));
142
+ this.bodyComponent = new Text(this.renderBodySlice(), 0, 0);
143
+ this.addChild(this.bodyComponent);
144
+ this.indicatorComponent = new Text(this.renderScrollIndicator(), 0, 0);
145
+ this.addChild(this.indicatorComponent);
146
+ }
147
+
110
148
  this.addChild(new Spacer(1));
111
149
  if (this.footerVisible) {
112
150
  this.footerText = new Text(this.footerValue(), 0, 0);
@@ -146,6 +184,17 @@ export class AskSelector extends Container {
146
184
  }
147
185
 
148
186
  handleInput(data: string): void {
187
+ if (this.bodyLines.length > this.maxBodyLines) {
188
+ const pageSize = Math.max(1, this.maxBodyLines - 2);
189
+ if (this.keybindings.matches(data, "tui.select.pageUp") || SCROLL_UP_KEYS.has(data)) {
190
+ this.scrollBody(-pageSize);
191
+ return;
192
+ }
193
+ if (this.keybindings.matches(data, "tui.select.pageDown") || SCROLL_DOWN_KEYS.has(data)) {
194
+ this.scrollBody(pageSize);
195
+ return;
196
+ }
197
+ }
149
198
  if (this.keybindings.matches(data, "tui.select.up")) {
150
199
  this.move(-1);
151
200
  return;
@@ -157,15 +206,39 @@ export class AskSelector extends Container {
157
206
  this.list.handleInput(data);
158
207
  }
159
208
 
160
- /**
161
- * Keyboard-only prompt (ADR-036). Deliberately does not call the inherited
162
- * Container dispatch: that would forward the event to the SelectList, which
163
- * picks the row a left click lands on and moves the highlight on wheel — a
164
- * stray click on the terminal could allow a call the user never chose.
165
- * Returning `undefined` means "not handled here"; the renderer may still use
166
- * the gesture for its own text selection, which cannot pick an answer.
167
- */
168
- handleMouse(): undefined {
209
+ private renderBodySlice(): string {
210
+ const slice = this.bodyLines.slice(this.bodyScrollOffset, this.bodyScrollOffset + this.maxBodyLines);
211
+ return this.theme.fg("accent", this.theme.bold(slice.join("\n")));
212
+ }
213
+
214
+ private renderScrollIndicator(): string {
215
+ const total = this.bodyLines.length;
216
+ const start = this.bodyScrollOffset + 1;
217
+ const end = Math.min(total, this.bodyScrollOffset + this.maxBodyLines);
218
+ return this.theme.fg("muted", `[${start}–${end} of ${total} lines | [/] or Shift+↑/↓ or Scroll Wheel]`);
219
+ }
220
+
221
+ private scrollBody(delta: number): void {
222
+ if (this.bodyLines.length <= this.maxBodyLines) return;
223
+ const maxOffset = this.bodyLines.length - this.maxBodyLines;
224
+ const next = Math.max(0, Math.min(maxOffset, this.bodyScrollOffset + delta));
225
+ if (next === this.bodyScrollOffset) return;
226
+ this.bodyScrollOffset = next;
227
+ this.bodyComponent?.setText(this.renderBodySlice());
228
+ this.indicatorComponent?.setText(this.renderScrollIndicator());
229
+ this.onActivity();
230
+ this.tui.requestRender();
231
+ }
232
+
233
+ handleMouse(event: { type?: string; wheelDelta?: number }): undefined {
234
+ if (event?.type === "wheel" && Number.isFinite(event.wheelDelta)) {
235
+ if (this.bodyLines.length > this.maxBodyLines) {
236
+ const delta = event.wheelDelta;
237
+ if (delta !== undefined) {
238
+ this.scrollBody(delta > 0 ? 2 : -2);
239
+ }
240
+ }
241
+ }
169
242
  return undefined;
170
243
  }
171
244