auto-permission-system 3.9.1 → 3.15.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.
@@ -34,8 +34,8 @@ import { onYolo } from "../src/shared/yolo";
34
34
  const detectionGrants = new Set<string>();
35
35
 
36
36
  function detectionBlock(action: "read" | "tool", why: string): string {
37
- if (action === "read") return `Read blocked by policy: ${why}. Nothing was read — ask the user.`;
38
- return `Tool call blocked by policy: ${why}. Nothing was run — ask the user.`;
37
+ if (action === "read") return `Read blocked by policy: ${why}. Nothing was read — ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
38
+ return `Tool call blocked by policy: ${why}. Nothing was run — ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
39
39
  }
40
40
 
41
41
  /** Maps each in-flight tool call to the program id we'll show in the
@@ -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) {
@@ -19,37 +19,32 @@ import { emitYolo, onYolo, YOLO_STATUS } from "../src/shared/yolo";
19
19
 
20
20
  const GLOBAL_POLICY = `${getAgentDir()}/extensions/sandbox.json`;
21
21
 
22
- const MODES: Array<{ label: string; value: PermissionMode }> = [
22
+ const MODE_DEFS: Array<{
23
+ value: PermissionMode;
24
+ icon: string;
25
+ name: string;
26
+ desc: string;
27
+ }> = [
23
28
  {
24
- label: "Advanced Secure (recommended) — detect secrets in reads, MCP calls, and output",
25
29
  value: "advanced-secure",
30
+ icon: "🧠",
31
+ name: "Advanced Secure",
32
+ desc: "Blocks sensitive data leaks from file reads, MCP tools & command outputs",
26
33
  },
27
34
  {
28
- label: "Default — rules only",
29
35
  value: "default",
36
+ icon: "🔒",
37
+ name: "Default",
38
+ desc: "Restricts file paths & network access at the sandbox level",
30
39
  },
31
40
  {
32
- label: "YOLO — disable ALL security layers",
33
41
  value: "yolo",
42
+ icon: "⚠️",
43
+ name: "YOLO",
44
+ desc: "Turns off all file sandboxing and leak protections",
34
45
  },
35
46
  ];
36
47
 
37
- const EXPLANATION = [
38
- "Advanced Secure (recommended)",
39
- " Adds secret/credential detection on top of the rules:",
40
- " • File reads — scans contents for keys, tokens, and passwords",
41
- " • MCP calls — flags risky tool calls before they run",
42
- " • Tool/command/file output — redacts secrets before they reach the model",
43
- "",
44
- "Default",
45
- " Rule-based only: sandbox.json paths, domains, and commands.",
46
- " No file contents are inspected.",
47
- "",
48
- "YOLO",
49
- " Turns every layer off. Saved like the other modes, so new conversations",
50
- " start with it too until you pick Default or Advanced Secure again.",
51
- ].join("\n");
52
-
53
48
  /**
54
49
  * The single sandbox footer chip: `Sandbox: <icon> N domains, M paths`.
55
50
  * `🧠` marks advanced-secure, `🔒` default; YOLO replaces it with the warning.
@@ -79,13 +74,23 @@ function modeNotice(mode: PermissionMode): { text: string; level: "info" | "warn
79
74
 
80
75
  async function pickMode(pi: ExtensionAPI, ctx: ExtensionContext, yolo: { active: boolean }): Promise<void> {
81
76
  const current: PermissionMode = yolo.active ? "yolo" : normalizeMode(loadPolicy(ctx.cwd).mode);
77
+
78
+ const options = MODE_DEFS.map((m) => {
79
+ const isActive = m.value === current;
80
+ const nameWithStatus = isActive ? `${m.name} (active)` : m.name;
81
+ const labelCol = nameWithStatus.padEnd(26);
82
+ return `${m.icon} ${labelCol} — ${m.desc}`;
83
+ });
84
+
82
85
  const picked = await ctx.ui.select(
83
- `Permission mode — current: ${modeLabel(current)}\n\n${EXPLANATION}\n\nEsc keeps the current mode.`,
84
- MODES.map((m) => m.label),
86
+ "Select permission mode:",
87
+ options,
85
88
  { timeout: 120_000 },
86
89
  );
87
90
  if (picked === undefined) return;
88
- const next = MODES.find((m) => m.label === picked)?.value ?? current;
91
+
92
+ const pickedIndex = options.indexOf(picked);
93
+ const next = pickedIndex >= 0 ? MODE_DEFS[pickedIndex].value : current;
89
94
  if (next === current) {
90
95
  if (next === "yolo" && !yolo.active) {
91
96
  yolo.active = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-permission-system",
3
- "version": "3.9.1",
3
+ "version": "3.15.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,8 @@
2
2
  "enabled": true,
3
3
  "mode": "advanced-secure",
4
4
  "network": {
5
+ "allowLocalhost": true,
6
+ "allowAllUnixSockets": true,
5
7
  "allowedDomains": [
6
8
  "registry.npmjs.org",
7
9
  "registry.yarnpkg.com",
@@ -24,13 +26,22 @@
24
26
  "rubygems.org",
25
27
  "api.nuget.org",
26
28
  "deno.land",
27
- "jsr.io"
29
+ "jsr.io",
30
+ "api.openai.com",
31
+ "huggingface.co",
32
+ "*.huggingface.co",
33
+ "cdn-lfs.huggingface.co",
34
+ "*.diffusion.studio",
35
+ "diffusion.studio"
28
36
  ]
29
37
  },
30
38
  "filesystem": {
31
39
  "outsideProject": {
32
40
  "read": "allow",
33
- "allowRead": []
41
+ "allowRead": [
42
+ "~/Downloads",
43
+ "~/.cache"
44
+ ]
34
45
  },
35
46
  "denyRead": [],
36
47
  "askRead": [
@@ -1,12 +1,21 @@
1
1
  ---
2
2
  name: auto-permission-system
3
- description: Use this skill when the user asks about auto-permission-system, security for Pi, permission system for Pi, how to secure Pi, Claude auto mode for Pi, security policy configuration, why a tool call was blocked, how to add an allow-list entry, or how to configure sandbox behavior. Covers the two-layer sandbox (OS-level bash sandbox and in-process tool guard), policy file locations and fields, Claude auto mode preset (sandbox.claude.json), ask-tier prompt options, absolute-deny paths, escape hatches (--yolo, --no-sandbox, /permission-mode → YOLO), the audit log, and common tasks like allowing a new domain or write path.
3
+ description: Use this skill ONLY when the user explicitly asks about auto-permission-system, security for Pi, configuring security policies, or permission modes. Do NOT invoke this skill automatically when an action or tool is blocked during regular coding tasks.
4
4
  ---
5
5
 
6
6
  # Security for Pi (Pi Permission System) — Skill
7
7
 
8
8
  Use this skill when the user asks about the auto-permission-system extension, security for Pi, permission system for Pi, how to secure Pi, or configuring Claude auto mode for Pi.
9
9
 
10
+ ## Handling Blocked Operations (Agent Behavior Guideline)
11
+
12
+ When a command, file operation, or tool call is blocked by the security policy:
13
+ - **Do not inspect plugin internals**: Do NOT read files inside `auto-permission-system`, do NOT inspect sandbox runtime code, and do NOT attempt to find bypasses or workarounds.
14
+ - **Explain clearly**: Calmly and concisely inform the user what specific action was restricted and why.
15
+ - **Ask to change permission mode**: Ask the user if they wish to adjust the permission mode using `/permission-mode` to allow the action.
16
+ - **Wait for user input**: Do not proceed with speculative workarounds or retry loops until the user responds or updates the mode.
17
+
18
+
10
19
  ## What this extension does
11
20
 
12
21
  **Layer 1 — Bash sandbox (OS-level)**
@@ -39,6 +48,7 @@ A higher layer only states what it changes; absent keys keep the layer below. A
39
48
  "enabled": true,
40
49
  "mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
41
50
  "network": {
51
+ "allowLocalhost": true, // true (default): localhost on all ports allowed; false: strict network isolation
42
52
  "allowedDomains": ["github.com", "*.github.com"], // empty = allow all
43
53
  "deniedDomains": [], // hard deny, never ask-able
44
54
  "strictAllowlist": false // true = deny unknown hosts instead of asking
@@ -31,6 +31,59 @@ 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
+ const isDiffusion = /diffusion studio is not running/i.test(output) || (command && /\b(?:dapi|diffusion)\b/i.test(command));
61
+ if (isDiffusion) {
62
+ return { host: "127.0.0.1", port: "3274", service: "Diffusion Studio" };
63
+ }
64
+
65
+ const curlMatch = output.match(/Failed to connect to (127\.0\.0\.1|localhost|::1) port (\d+)/i);
66
+ if (curlMatch) {
67
+ return {
68
+ host: curlMatch[1],
69
+ port: curlMatch[2],
70
+ service: curlMatch[2] === "3274" ? "Diffusion Studio" : undefined,
71
+ };
72
+ }
73
+
74
+ const nodeMatch = output.match(/(?:ECONNREFUSED|connect to)\s+(127\.0\.0\.1|localhost|::1)(?::| port )(\d+)/i);
75
+ if (nodeMatch) {
76
+ return {
77
+ host: nodeMatch[1],
78
+ port: nodeMatch[2],
79
+ service: nodeMatch[2] === "3274" ? "Diffusion Studio" : undefined,
80
+ };
81
+ }
82
+
83
+ const hostMatch = output.match(/(127\.0\.0\.1|localhost|::1)/i);
84
+ return { host: hostMatch ? hostMatch[1] : "127.0.0.1" };
85
+ }
86
+
34
87
  /** A shell-name/empty token is not a path; everything else resolves against cwd unless absolute. */
35
88
  function toAbsolutePath(rawToken: string, cwd: string, home: string): string | undefined {
36
89
  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 {
@@ -29,7 +30,7 @@ export interface SandboxedBashOpts {
29
30
 
30
31
  /** One denial shape for Layer 1, mirroring denyMessage() in Layer 2. */
31
32
  function blockedLine(action: "Read" | "Write", why: string, outcome: "read" | "written" | "run"): string {
32
- return `❌ pi-sandbox: ${action} blocked by policy: ${why}. Nothing was ${outcome} — ask the user.`;
33
+ return `🛡️ pi-sandbox: ${action} blocked by policy: ${why}. Nothing was ${outcome}. Assistant guideline: Inform the user and ask if they wish to adjust the permission mode (/permission-mode); do not attempt workarounds or inspect plugin internals.`;
33
34
  }
34
35
 
35
36
  /** `Link` body row for a symlinked block path; empty when the spellings agree (ADR-035). */
@@ -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
 
@@ -300,32 +327,46 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
300
327
  }
301
328
  readDenied = hardDenied || outsideDenied;
302
329
  }
303
- const configDirHint = offending && /\.config\/|\.kube\/|\.docker\/|\.netrc|\.aws\/|\.npmrc|\.gitconfig/.test(offending);
304
-
305
- blockedHint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
330
+ blockedHint = `\n🛡️ Security Policy Notice: filesystem access blocked by sandbox policy.\n`;
306
331
  if (offending) {
307
332
  let why = "";
308
- if (writeDenied && !hardDenied) why = " (not under an allowWrite root: writes outside it are read-only)";
333
+ if (writeDenied && !hardDenied) why = " (writes outside allowWrite are read-only)";
309
334
  else if (hardDenied) why = ` (${hardWhy})`;
310
- else if (outsideDenied) why = " (outside the project: reading it is gated by policy)";
311
- blockedHint += ` Path: ${offending}${why}\n`;
312
- if (spelledOffending && spelledOffending !== offending) blockedHint += ` Link: ${spelledOffending} (symlink target shown as Path)\n`;
313
- }
314
- blockedHint += ` This is the pi sandbox (Layer 1), NOT macOS Full Disk Access / TCC.\n`;
315
- if (configDirHint) {
316
- blockedHint +=
317
- ` Looks like a tool's own config dir. To allow this tool in the\n` +
318
- ` current project, add a project-local policy:\n` +
319
- ` mkdir -p ${cwd}/.pi && cat > ${cwd}/.pi/sandbox.json <<'JSON'\n` +
320
- ` { "filesystem": { "allowWrite": [".", "${offending?.replace(/^~/, "$HOME") ?? "~/.config/<tool>"}"] } }\n` +
321
- ` JSON\n` +
322
- ` Or run pi with --yolo for one-off elevated access (disables ALL layers).\n`;
323
- } else {
324
- blockedHint +=
325
- ` Use $TMPDIR (= ${piTmp}/) for scratch files,\n` +
326
- ` or write inside the project directory (${cwd}).\n`;
335
+ else if (outsideDenied) why = " (outside the project directory)";
336
+ blockedHint += ` Target: ${offending}${why}\n`;
337
+ if (spelledOffending && spelledOffending !== offending) blockedHint += ` Link: ${spelledOffending}\n`;
327
338
  }
328
- blockedHint += ` Policy: ~/.pi/agent/extensions/sandbox.json (+ <cwd>/.pi/sandbox.json overrides).\n`;
339
+ blockedHint +=
340
+ ` Explanation: The current sandbox policy restricts this operation to protect your system.\n` +
341
+ ` Guideline for Assistant:\n` +
342
+ ` • Stop and inform the user clearly about what was blocked.\n` +
343
+ ` • Ask the user if they wish to adjust the permission mode (/permission-mode) to allow this action.\n` +
344
+ ` • Do not inspect security plugin internals or attempt unauthorized bypasses.\n`;
345
+ } else if (code !== 0 && isLoopbackConnectionRefused(outputTail)) {
346
+ const endpointInfo = extractLoopbackEndpoint(outputTail, command);
347
+ const endpointStr = endpointInfo?.port ? `${endpointInfo.host}:${endpointInfo.port}` : (endpointInfo?.host ?? "127.0.0.1");
348
+ const serviceName = endpointInfo?.service;
349
+
350
+ auditL1({
351
+ subject: endpointStr,
352
+ decision: "loopback-refused",
353
+ reason: "Network namespace isolates host loopback under current policy",
354
+ cwd: opts?.ctx?.cwd,
355
+ note: serviceName ? `service:${serviceName}` : undefined,
356
+ });
357
+
358
+ const notifyMsg = serviceName
359
+ ? `pi-sandbox: connection to ${serviceName} on ${endpointStr} was restricted by the sandbox.`
360
+ : `pi-sandbox: connection to host ${endpointStr} was restricted by the sandbox.`;
361
+ opts?.ctx?.ui?.notify?.(notifyMsg, "warning");
362
+
363
+ blockedHint = `\n🛡️ Security Policy Notice: Local network connection restricted.\n` +
364
+ ` Target: ${endpointStr}${serviceName ? ` (${serviceName})` : ""}\n` +
365
+ ` Explanation: The sandbox policy restricted access to this local host address.\n` +
366
+ ` Guideline for Assistant:\n` +
367
+ ` • Stop and inform the user clearly about what connection was restricted.\n` +
368
+ ` • Ask the user if they wish to adjust the permission mode (/permission-mode) to allow this action.\n` +
369
+ ` • Do not inspect security plugin internals or attempt unauthorized bypasses.\n`;
329
370
  }
330
371
  /**
331
372
  * 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,8 @@ const BUILTIN_CONFIG: SandboxConfig = {
48
64
  network: {
49
65
  allowedDomains: [...BUILTIN_NETWORK_ALLOWED],
50
66
  deniedDomains: [],
67
+ allowLocalhost: true,
68
+ allowAllUnixSockets: true,
51
69
  },
52
70
  filesystem: {
53
71
  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,18 @@ 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 allowAllUnixSockets = config.allowAllUnixSockets ?? config.network?.allowAllUnixSockets ?? true;
71
+ const network = config.network
72
+ ? {
73
+ ...config.network,
74
+ allowAllUnixSockets,
75
+ allowLocalBinding: config.allowLocalBinding ?? config.network.allowLocalBinding ?? allowLocalhost,
76
+ }
77
+ : undefined;
78
+
67
79
  return {
68
- network: config.network,
80
+ network: network as NonNullable<SandboxRuntimeConfig["network"]>,
69
81
  filesystem: config.filesystem
70
82
  ? sandboxFilesystem(config.filesystem, { cwd, home: homedir() })
71
83
  : { denyRead: [], allowRead: [], allowWrite: [], denyWrite: [], disabled: true },
@@ -87,7 +99,8 @@ export async function initSandbox(cwd: string, config: SandboxConfig, deps?: Net
87
99
  export async function resetSandbox(): Promise<void> {
88
100
  try {
89
101
  await SandboxManager.reset();
90
- } catch {
102
+ } catch (err) {
103
+ void err;
91
104
  }
92
105
  }
93
106
 
@@ -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
 
package/src/ui/ask.ts CHANGED
@@ -67,9 +67,59 @@ function actionOf(ask: AskKind): AskAction {
67
67
  return AskAction.Read;
68
68
  }
69
69
 
70
- /** The reason without the trailing `→ /abs/path` (the body already shows the path). */
70
+ /** Format the reason into plain, user-friendly language for non-technical users. */
71
71
  export function displayWhy(reason: string): string {
72
- return reason.replace(/\s*→\s*.*$/, "");
72
+ const raw = reason.replace(/\s*→\s*.*$/, "").trim();
73
+
74
+ if (raw.startsWith("absolute-deny matched")) {
75
+ const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
76
+ if (pat.includes("ssh")) return `contains private SSH keys (${pat})`;
77
+ if (pat.includes("aws")) return `contains AWS credentials (${pat})`;
78
+ if (pat.includes("gnupg")) return `contains private encryption keys (${pat})`;
79
+ if (pat.includes("pem") || pat.includes("key")) return `contains private security keys (${pat})`;
80
+ if (pat.includes("auth.json")) return "holds login credentials";
81
+ return `contains sensitive credentials (${pat || "credentials"})`;
82
+ }
83
+
84
+ if (raw.startsWith("askRead matched")) {
85
+ const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
86
+ if (pat.includes(".env")) return ".env files usually hold passwords and API keys";
87
+ return `this file usually holds private passwords or API keys (${pat})`;
88
+ }
89
+
90
+ if (raw.startsWith("denyRead matched")) {
91
+ const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
92
+ if (pat.includes(".env")) return ".env files usually hold passwords and API keys";
93
+ return `protected by security rules from being read (${pat})`;
94
+ }
95
+
96
+ if (raw.startsWith("modelDenyRead matched")) {
97
+ const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
98
+ return `contains private data hidden from the AI (${pat})`;
99
+ }
100
+
101
+ if (raw.startsWith("denyWrite matched")) {
102
+ const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
103
+ return `protected file that cannot be modified (${pat})`;
104
+ }
105
+
106
+ if (raw.includes("not under any allowWrite root") || raw.includes("not under an allowWrite root")) {
107
+ return "saving files outside your project workspace requires permission";
108
+ }
109
+
110
+ if (raw.startsWith("outside the project")) {
111
+ return "reading files outside your project folder requires permission";
112
+ }
113
+
114
+ if (raw.startsWith("domain not in allowlist")) {
115
+ return "this site isn't on your allowed list yet";
116
+ }
117
+
118
+ if (raw === "blocked by the sandbox filesystem policy") {
119
+ return "restricted by your project's security policy";
120
+ }
121
+
122
+ return raw;
73
123
  }
74
124
 
75
125
  /** A domain reason without its `: host` suffix, so it can sit next to `Site:` without repeating it. */
@@ -79,9 +129,9 @@ export function networkWhy(reason: string): string {
79
129
 
80
130
  /** The model-facing denial text: one pattern for every block. */
81
131
  export function denyMessage(overrideKind: OverrideKind, reason: string): string {
82
- if (overrideKind === "allowDomains") return `Network blocked by policy: ${networkWhy(reason)}. Nothing was fetched \u2014 ask the user.`;
83
- if (overrideKind === "allowWrite") return `Write blocked by policy: ${displayWhy(reason)}. Nothing was written \u2014 ask the user.`;
84
- return `Read blocked by policy: ${displayWhy(reason)}. Nothing was read \u2014 ask the user.`;
132
+ if (overrideKind === "allowDomains") return `Network blocked by policy: ${networkWhy(reason)}. Nothing was fetched \u2014 ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
133
+ if (overrideKind === "allowWrite") return `Write blocked by policy: ${displayWhy(reason)}. Nothing was written \u2014 ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
134
+ return `Read blocked by policy: ${displayWhy(reason)}. Nothing was read \u2014 ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
85
135
  }
86
136
 
87
137
  /**