auto-permission-system 3.0.0 → 3.6.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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ It puts allow/deny rules, interactive ask prompts, secret detection and an audit
11
11
 
12
12
  Enforcement happens in three layers:
13
13
 
14
- - **Layer 1** — OS-level bash sandbox (`sandbox-exec` on macOS, `bubblewrap` on Linux) that blocks filesystem writes outside an allow-list, reads of sensitive paths, reads outside the project when `outsideProject.read` gates them, and network egress to unlisted domains.
14
+ - **Layer 1** — OS-level bash sandbox (`sandbox-exec` on macOS, `bubblewrap` on Linux) that blocks filesystem writes outside an allow-list, reads of sensitive paths, reads outside the project when `outsideProject.read` gates them, and network egress to unlisted domains — an unknown host asks first (a host in `deniedDomains` never does), with the same once/session/remember options as Layer 2.
15
15
  - **Layer 2** — In-process tool guard applying the same policy to the tools the OS sandbox can't reach: `read`, `grep`, `find`, `ls`, `write`, `edit`, `fetch_content`, `web_search`, `get_search_content`. `grep` output lines from denied files beneath an allowed search root are removed before the model sees them.
16
16
  - **Layer 3** — Subagent posture: optionally drop or restrict network access when running headless (`-p`, JSON mode, subagents).
17
17
 
@@ -72,11 +72,12 @@ A higher layer only states what it changes; absent keys keep the layer below. A
72
72
  "enabled": true, // set false to disable Layer 2 without --yolo
73
73
  "mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
74
74
  "network": {
75
- "allowedDomains": [ // domains fetch_content / get_search_content may reach
75
+ "allowedDomains": [ // domains the URL tools and bash may reach
76
76
  "github.com", "*.github.com",
77
77
  "registry.npmjs.org"
78
78
  ],
79
- "deniedDomains": [] // explicit block-list (checked before allowedDomains)
79
+ "deniedDomains": [], // hard deny, checked first, never ask-able
80
+ "strictAllowlist": false // true = deny unknown hosts instead of asking
80
81
  },
81
82
  "filesystem": {
82
83
  "denyRead": ["~/.ssh", "~/.aws", "~/.pi/agent"], // Layer 1 + Layer 2: no read at all
@@ -123,9 +124,9 @@ Never asked about: the project itself, your `allowWrite` roots, pi's own package
123
124
 
124
125
  ### Ask-tier prompt
125
126
 
126
- `no — block` is pre-selected, so Enter alone blocks. Besides `yes — this once` and the persistent `always` options there is `yes — for this session` (this file, its folder, or this domain), kept in memory until the session ends and never saved (ADR-010). For a bash write that the sandbox refused after the command already ran, `yes — this once` re-runs the command once with that folder allowed for the one invocation only (never saved), and `denyWrite` still wins.
127
+ `no — block` is pre-selected, so Enter alone blocks. **Network (domain) asks are the exception** (ADR-024): the countdown default is `Allow once` — the footer reads `Default: Yes, just this once (15s)` — so an unanswered countdown counts as allow-once for that bash command, while Enter and Esc still block/deny. Once you press ↑/↓ the `Default:` line disappears and the 15s activity timer restarts (ADR-031). Besides `yes — this once` and the persistent `always` options there is `yes — for this session` (this file, its folder, or this domain), kept in memory until the session ends and never saved (ADR-010). A bash command that reaches a domain outside `allowedDomains` gets the same prompt (ADR-023); because the sandbox proxy asks per connection, `yes — this once` covers that command's connections to the host, and a remembered host is written to `overrides.allowDomains` and applies immediately. Hosts in `deniedDomains`, or with `strictAllowlist: true`, are blocked without a prompt. Prompts are shown one at a time: the extension serializes them, because pi's selector is a singleton and two overlapping prompts can orphan each other and hang the waiting connection. For a bash write that the sandbox refused after the command already ran, `yes — this once` re-runs the command once with that folder allowed for the one invocation only (never saved), and `denyWrite` still wins. The refusal is read from the command's own error text — `EPERM` / `Operation not permitted` on macOS, `EROFS` / `Read-only file system` on Linux (ADR-022) — so a Linux EROFS gets the same prompt instead of a silent read-only failure; a refused write is never mistaken for an outside-project read.
127
128
 
128
- Every prompt waits **10 seconds** by default. If you do not answer, it resolves to the safe default: **block / deny**.
129
+ Every prompt waits **10 seconds** by default. For file reads and writes, no answer resolves to the safe default: **block / deny**. For a network ask, no answer resolves to **allow once** (ADR-024); Esc still denies.
129
130
 
130
131
  ### File-name patterns
131
132
 
@@ -200,7 +201,7 @@ Every block/allow/always decision is appended to `~/.pi/agent/audit.log` as a JS
200
201
  ## FAQ
201
202
 
202
203
  ### How do I allow the agent to reach a new domain?
203
- Add it to `network.allowedDomains` in a policy file (global `~/.pi/agent/extensions/sandbox.json` or project `<cwd>/.pi/sandbox.json`), or just run the tool once and pick an **always** option in the ask prompt — that writes the domain to the project file for you.
204
+ Add it to `network.allowedDomains` in a policy file (global `~/.pi/agent/extensions/sandbox.json` or project `<cwd>/.pi/sandbox.json`), or just run the tool (or a bash `curl`) once and pick an **always** option in the ask prompt — that writes the domain to the project file for you.
204
205
 
205
206
  ### How do I allow writes to another folder?
206
207
  Add the folder to `filesystem.allowWrite`. Relative paths (`.`, `/tmp`) resolve from the project root.
@@ -16,10 +16,13 @@ import { collectExposure } from "../src/l2-guard/exposure";
16
16
  import { askExposure, askMain, withheldNotice } from "../src/ui/ask-flow";
17
17
  import { loadPolicy, projectPolicyPath, projectTrusted, setPiDeclinedTrust, untrustedProjectChanges, bullets, TRUST_STORE } from "../src/l2-guard/policy";
18
18
  import { canonicalize, expandHome, isAbsoluteDeny, isAskRead, isDeniedRead, isDeniedWrite, outsideProjectReason } from "../src/l2-guard/matching";
19
- import { hostnameOf, isAllowedUrl } from "../src/l2-guard/url";
19
+ import { deniedUrlReason, hostnameOf, isAllowedUrl } from "../src/l2-guard/url";
20
20
  import { audit, AUDIT_PATH } from "../src/shared/audit";
21
21
  import { askOrBlock, clearSessionGrants, sessionGrantSummary, type UICtx } from "../src/l2-guard/prompts";
22
- import { denyMessage } from "../src/l2-guard/ask";
22
+ import { grantProgramOutputSession, isProgramOutputGranted, clearProgramOutputSessionGrants, programOutputSessionGrantSummary } from "../src/l2-guard/program-grants";
23
+ import { programIdForToolCall } from "../src/l2-guard/program-id";
24
+ import { denyMessage } from "../src/ui/ask";
25
+ import { emitDomainGrant } from "../src/shared/network-grants";
23
26
  import { subagentNetworkBlock } from "../src/l2-guard/subagent";
24
27
  import { onYolo } from "../src/shared/yolo";
25
28
 
@@ -35,6 +38,11 @@ function detectionBlock(action: "read" | "tool", why: string): string {
35
38
  return `Tool call blocked by policy: ${why}. Nothing was run — ask the user.`;
36
39
  }
37
40
 
41
+ /** Maps each in-flight tool call to the program id we'll show in the
42
+ * Advanced Secure output gate. Populated at `tool_call`, consumed at
43
+ * `tool_result`, cleared at `session_start`. */
44
+ const programIdByCallId = new Map<string, string>();
45
+
38
46
  async function askDetection(
39
47
  ctx: UICtx,
40
48
  kind: "read" | "tool",
@@ -135,12 +143,20 @@ export default function (pi: ExtensionAPI) {
135
143
  applyActive();
136
144
  clearSessionGrants();
137
145
  detectionGrants.clear();
146
+ clearProgramOutputSessionGrants();
147
+ programIdByCallId.clear();
138
148
  });
139
149
 
140
150
  pi.on("tool_call", async (event, ctx) => {
141
151
  if (!active) return;
142
152
  const policy = loadPolicy(ctx.cwd);
143
153
 
154
+ // Capture the program id (binary for bash, MCP server for mcp__ tools) so the
155
+ // Advanced Secure output gate can label the prompt and honor a session grant
156
+ // keyed by that program. Falls back to the tool name when nothing else fits.
157
+ const programId = programIdForToolCall(event.toolName, event.input);
158
+ if (event.toolCallId && programId) programIdByCallId.set(event.toolCallId, programId);
159
+
144
160
  // --- Path-based gates (with ask-tier prompt) ---
145
161
  // One gate for every read: the read policy first, then the project boundary.
146
162
  const gateRead = async (rawPath: string, tool: string) => {
@@ -218,10 +234,20 @@ export default function (pi: ExtensionAPI) {
218
234
  const saReason = subagentNetworkBlock(ctx as { hasUI?: boolean; sessionManager?: unknown }, policy);
219
235
  if (saReason) return { block: true, reason: saReason };
220
236
  for (const u of collectUrls()) {
237
+ // deniedDomains is the hard-deny tier: the bash proxy refuses it before
238
+ // any ask, so Layer 2 must not offer to override it either.
239
+ const denied = deniedUrlReason(u, policy);
240
+ if (denied) {
241
+ audit({ layer: 2, tool: event.toolName, subject: u, reason: denied, decision: "no", note: "denied-domain", cwd: ctx.cwd });
242
+ return { block: true, reason: denyMessage("allowDomains", denied) };
243
+ }
221
244
  const reason = isAllowedUrl(u, policy);
222
245
  if (!reason) continue;
223
246
  const host = hostnameOf(u) ?? u;
224
- const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, { layer: 2, tool: event.toolName, subject: u, reason, overrideKind: "allowDomains", overrideValue: host }, null);
247
+ const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, { layer: 2, tool: event.toolName, subject: u, reason, overrideKind: "allowDomains", overrideValue: host }, null, (kind, value) => {
248
+ // Layer 1's proxy does not re-read the file: apply the remembered host live (ADR-023).
249
+ if (kind === "allowDomains") emitDomainGrant(pi.events, value);
250
+ });
225
251
  if (result) return result;
226
252
  }
227
253
  }
@@ -281,17 +307,35 @@ export default function (pi: ExtensionAPI) {
281
307
  if (!active || event.isError) return;
282
308
  const policy = loadPolicy(ctx.cwd);
283
309
  if (policy.mode !== "advanced-secure") return;
310
+ const programId = event.toolCallId ? programIdByCallId.get(event.toolCallId) : undefined;
311
+ if (programId && isProgramOutputGranted(programId)) {
312
+ audit({ layer: 2, tool: event.toolName, decision: "session-grant", grant: programId, note: "advanced-secure-output", cwd: ctx.cwd });
313
+ return;
314
+ }
284
315
  const rawInput = event.input ?? {};
285
316
  const rawPath = rawInput.path ?? rawInput.file_path;
286
- const subject = typeof rawPath === "string" && rawPath ? rawPath : event.toolName;
317
+ const subject = (() => {
318
+ if (typeof rawPath === "string" && rawPath) return rawPath;
319
+ if (event.toolName === "bash") {
320
+ const cmd = (event.input as { command?: string } | undefined)?.command;
321
+ return cmd ? `bash: ${cmd}` : "bash";
322
+ }
323
+ return event.toolName;
324
+ })();
287
325
  const { types, hits } = collectExposure(event.content, subject);
288
326
  if (!types.length) return;
289
327
  const findings = types;
290
328
  const ui = /* SAFETY: pi's ctx carries cwd/UI at runtime; the local type only names the members used. */ ctx as unknown as UICtx;
291
- const allow = ui.hasUI !== false && (await askExposure(ui, hits)) === "allow";
292
- if (allow) {
293
- audit({ layer: 2, tool: event.toolName, decision: "yes", note: "advanced-secure-output", findings, cwd: ctx.cwd });
294
- ctx.ui.notify(`🛡 Advanced Secure: you allowed ${event.toolName} output to be sent.`, "warning");
329
+ const choice = ui.hasUI !== false ? await askExposure(ui, hits, programId) : "block";
330
+ if (choice === "allow" || choice === "program-session") {
331
+ if (choice === "program-session" && programId) {
332
+ grantProgramOutputSession(programId);
333
+ audit({ layer: 2, tool: event.toolName, decision: "session", grant: programId, note: "advanced-secure-output", findings, cwd: ctx.cwd });
334
+ ctx.ui.notify(`🛡 Advanced Secure: you allowed all ${programId} output for the rest of this session.`, "info");
335
+ } else {
336
+ audit({ layer: 2, tool: event.toolName, decision: "yes", note: "advanced-secure-output", findings, cwd: ctx.cwd });
337
+ ctx.ui.notify(`🛡 Advanced Secure: you allowed ${event.toolName} output to be sent.`, "warning");
338
+ }
295
339
  return;
296
340
  }
297
341
  audit({ layer: 2, tool: event.toolName, decision: "no", note: "advanced-secure-output", findings, cwd: ctx.cwd });
@@ -31,8 +31,11 @@
31
31
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
32
  import { createBashTool } from "@earendil-works/pi-coding-agent";
33
33
  import { createSandboxedBashOps } from "../src/l1-sandbox/bash-ops";
34
- import { initSandbox, persistLayer1Override, reloadSandbox, resetSandbox } from "../src/l1-sandbox/manager";
34
+ import { applyNetworkGrant, initSandbox, persistLayer1Override, reloadSandbox, resetSandbox, writeNetworkOverride } from "../src/l1-sandbox/manager";
35
+ import { clearNetworkSessionGrants, type NetworkAskCtx, type NetworkAskDeps } from "../src/l1-sandbox/network-ask";
35
36
  import { loadConfig, setPiDeclinedTrust } from "../src/l1-sandbox/config";
37
+ import { audit } from "../src/shared/audit";
38
+ import { onDomainGrant } from "../src/shared/network-grants";
36
39
  import { emitYolo, onYolo, registerYoloFlags, yoloFromFlags, YOLO_STATUS } from "../src/shared/yolo";
37
40
 
38
41
  export default function (pi: ExtensionAPI) {
@@ -40,6 +43,10 @@ export default function (pi: ExtensionAPI) {
40
43
  // `yoloFromFlags(pi)` below read the real CLI value.
41
44
  registerYoloFlags(pi);
42
45
 
46
+ // A domain remembered from the Layer 2 guard prompt is written to
47
+ // sandbox.json by the guard; apply it to the running proxy here (ADR-023).
48
+ onDomainGrant(pi.events, applyNetworkGrant);
49
+
43
50
  const localCwd = process.cwd();
44
51
  const localBash = createBashTool(localCwd);
45
52
 
@@ -48,10 +55,25 @@ export default function (pi: ExtensionAPI) {
48
55
  let runtimeYolo = false;
49
56
  let sandboxStarting: Promise<void> | null = null;
50
57
  let latestCtx: ExtensionContext | undefined;
58
+ let latestBashCommand: string | undefined;
51
59
  let activeCtx: { cwd: string; hasUI?: boolean; ui?: { select?: (t: string, o: string[], op?: { timeout?: number }) => Promise<string | undefined>; notify?: (m: string, l?: string) => void } } | undefined;
52
60
 
53
- const persistAndReload = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowWrite", absPath, scope);
54
- const persistAndReloadRead = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowRead", absPath, scope);
61
+ const persistAndReload = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowWrite", absPath, scope, networkDeps(localCwd));
62
+ const persistAndReloadRead = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowRead", absPath, scope, networkDeps(localCwd));
63
+
64
+ /** Layer 1 network ask wiring (ADR-023): the proxy asks for unknown hosts and applies grants live. */
65
+ const networkDeps = (cwd: string): NetworkAskDeps => ({
66
+ cwd,
67
+ getCtx: () => /* SAFETY: pi's runtime ctx carries ui.select/ui.notify; the local type names only the members used. */ activeCtx as unknown as NetworkAskCtx | undefined,
68
+ persist: (host, scope) => writeNetworkOverride(cwd, host, scope),
69
+ applyLive: applyNetworkGrant,
70
+ audit,
71
+ // Lazy getters so each proxy ask reads the live trust state and the most
72
+ // recent bash command. Without these, the values captured at initSandbox()
73
+ // would be stale.
74
+ get projectTrusted() { return latestCtx?.isProjectTrusted?.(); },
75
+ get command() { return latestBashCommand; },
76
+ });
55
77
 
56
78
  /** Bring the bash sandbox down; safe when it was never up. */
57
79
  const stopSandbox = async (): Promise<void> => {
@@ -77,7 +99,7 @@ export default function (pi: ExtensionAPI) {
77
99
  return;
78
100
  }
79
101
  try {
80
- await initSandbox(ctx.cwd, config);
102
+ await initSandbox(ctx.cwd, config, networkDeps(ctx.cwd));
81
103
  sandboxEnabled = true;
82
104
  sandboxInitialized = true;
83
105
  const networkCount = config.network?.allowedDomains?.length ?? 0;
@@ -140,9 +162,18 @@ export default function (pi: ExtensionAPI) {
140
162
  return { operations: createSandboxedBashOps({ ctx: activeCtx, onAlways: persistAndReload, onAlwaysRead: persistAndReloadRead }) };
141
163
  });
142
164
 
165
+ // Capture the latest bash command so the network ask body can show it.
166
+ pi.on("tool_call", (event, _ctx) => {
167
+ if (event.toolName === "bash") {
168
+ const input = event.input as { command?: string };
169
+ if (typeof input.command === "string") latestBashCommand = input.command;
170
+ }
171
+ });
172
+
143
173
  pi.on("session_start", async (_event, ctx) => {
144
174
  latestCtx = ctx;
145
175
  activeCtx = /* SAFETY: pi's runtime ctx carries cwd/hasUI/ui; the local type only names the fields we use. */ ctx as unknown as typeof activeCtx;
176
+ clearNetworkSessionGrants();
146
177
  setPiDeclinedTrust((ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() === false);
147
178
  runtimeYolo = yoloFromFlags(pi) || loadConfig(ctx.cwd).mode === "yolo";
148
179
  // Broadcast before any await: guard.ts consumes this on the same bus to
@@ -175,7 +206,7 @@ export default function (pi: ExtensionAPI) {
175
206
  return;
176
207
  }
177
208
  try {
178
- await reloadSandbox(localCwd);
209
+ await reloadSandbox(localCwd, networkDeps(localCwd));
179
210
  ctx.ui.notify("🔄 Sandbox reloaded from disk (global + project sandbox.json)", "info");
180
211
  } catch (e) {
181
212
  ctx.ui.notify(`Sandbox reload failed: ${e instanceof Error ? e.message : e}`, "error");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-permission-system",
3
- "version": "3.0.0",
3
+ "version": "3.6.0",
4
4
  "description": "Security sandbox, permission system and tool guard for the Pi coding agent (pi): enforce filesystem allow/deny lists and network domain allowlists on read, write, edit, bash, grep, find, ls, fetch_content and web_search, with interactive ask prompts, secret detection and an audit log.",
5
5
  "type": "module",
6
6
  "author": "Navin Kumar",
@@ -39,7 +39,8 @@
39
39
  "@anthropic-ai/sandbox-runtime": "^0.0.78"
40
40
  },
41
41
  "peerDependencies": {
42
- "@earendil-works/pi-coding-agent": "*"
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@commitlint/cli": "^21.2.3",
@@ -14,7 +14,7 @@ Wraps the `bash` tool with `sandbox-exec` (macOS) or `bubblewrap` (Linux). Block
14
14
  - Filesystem writes outside `allowWrite`
15
15
  - Filesystem reads of `denyRead` paths (defaults include `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.pi/agent`)
16
16
  - Filesystem reads outside the project when `outsideProject.read` is `"ask"` or `"deny"` — when set, the home dir (and the project's parent) is fenced with `denyRead` and the project plus `allowWrite`/`outsideProject.allowRead` are re-exposed (ADR-014). With `"ask"`, a bash command that *plainly* names an outside path is confirmed with you **before it runs** (ADR-015): allow (`yes — this once`, or a persistent `always` grant) → the real, complete output; deny → the command is not run at all (no silently trimmed output). `install`/`npm` under `~/.pi/agent` are pre-allowed via `outsideProject.allowRead`, so they never prompt. Obfuscated reads still hit the fence and stay masked.
17
- - Network egress to domains not in `allowedDomains`
17
+ - Network egress to domains not in `allowedDomains`: a host in neither list asks before the proxy decides (ADR-023) — `yes — this once` covers that bash command's connections to the host, `yes — for this session` lasts until the session ends, and `always` writes `overrides.allowDomains` and applies live. A host in `deniedDomains`, or `network.strictAllowlist: true`, is a hard deny that is never ask-able; headless blocks.
18
18
 
19
19
  **Layer 2 — In-process tool guard**
20
20
  Hooks `tool_call` for `read`, `grep`, `find`, `ls`, `write`, `edit`, `fetch_content`, `web_search`, `get_search_content`. Applies the same policy file as Layer 1. For `grep`, `find` and `ls` the search root is checked like a `read` path. A `tool_result` hook also removes `grep` output lines from denied files beneath an allowed root (for example a `.env` deep in the tree); approving the root does not approve those files. When a call is blocked, shows an interactive prompt with persistence options.
@@ -39,7 +39,8 @@ A higher layer only states what it changes; absent keys keep the layer below. A
39
39
  "mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
40
40
  "network": {
41
41
  "allowedDomains": ["github.com", "*.github.com"], // empty = allow all
42
- "deniedDomains": []
42
+ "deniedDomains": [], // hard deny, never ask-able
43
+ "strictAllowlist": false // true = deny unknown hosts instead of asking
43
44
  },
44
45
  "filesystem": {
45
46
  "denyRead": ["~/.ssh", "~/.pi/agent"], // Layer 1 + 2: hard block (defaults add ~/.aws, ~/.gnupg)
@@ -83,9 +84,13 @@ When a normal (non-absolute-deny) call is blocked:
83
84
  - **always for ALL projects** — whitelist in `~/.pi/agent/extensions/sandbox.json`
84
85
  - **always for ALL projects (folder)** — whitelist parent directory globally
85
86
 
86
- For a Layer 1 bash **write** blocked after the command already ran, `yes — this once` re-runs the command once with the offending path's parent folder allowed for that invocation only (never saved); the same folder is not offered again within that invocation, and `denyWrite` still wins over the grant.
87
+ A **network** (domain) ask keeps the same rows, but the *unanswered* default flips (ADR-024): the footer reads `Default: Yes, just this once (15s)`, and a countdown that runs out counts as **allow once for this bash command**. `No` is still preselected (Enter blocks) and Esc still denies. A domain in `deniedDomains` (or `network.strictAllowlist: true`) never prompts.
87
88
 
88
- Every prompt waits **10 seconds** by default; no answer means the safe default: **block / deny**. Esc on any screen also blocks.
89
+ For a Layer 1 bash **write** blocked after the command already ran, `yes — this once` re-runs the command once with the offending path's parent folder allowed for that invocation only (never saved); the same folder is not offered again within that invocation, and `denyWrite` still wins over the grant. The block is recognized from the error output either way it is reported — `EPERM`/`Operation not permitted` on macOS, `EROFS`/`Read-only file system` on Linux (ADR-022) — and a refused write is never offered as a read grant. If the output never names the operand, there is nothing to attribute and no prompt, so the command is simply refused.
90
+
91
+ Every prompt waits **15 seconds** by default and shows it live as `Default: … (Ns)` under the body. For file reads/writes no answer means the safe default: **block / deny**, and Esc blocks. For a network ask, an unanswered countdown means **allow once** (Esc still denies) — ADR-024. In the TUI the `Default:` line disappears as soon as you press ↑/↓ and the timer restarts, so the hint never competes with an active choice (ADR-031).
92
+
93
+ The same options apply to a bash command whose network request reaches a host outside `allowedDomains` (ADR-023), with the countdown default and live footer from ADR-024/ADR-031: `No` / `Yes, just this once` / `Yes, all in group for this session` / `Yes, always…`, and an untouched countdown counts as allow-once. Because the sandbox proxy asks per connection, `Allow (default)` covers that bash command rather than one connection — redirects and parallel connections to the same host do not re-prompt. A host in `deniedDomains`, or `network.strictAllowlist: true`, is blocked outright: the prompt never appears. A remembered host is written to `overrides.allowDomains` and takes effect immediately, so the waiting request completes without a retry. Prompts from both layers are serialized through one queue (`src/ui/ask-flow.ts`), so two unknown hosts cannot replace each other's selector and hang the waiting connection.
89
94
 
90
95
  ## Sensitive reads ask
91
96
 
@@ -129,7 +134,7 @@ Without restarting, `/permission-mode` → **YOLO** turns every layer off in one
129
134
 
130
135
  ## Common tasks
131
136
 
132
- **Allow a new domain** — either answer "always" in the prompt when the block fires, or add it manually:
137
+ **Allow a new domain** — either answer "always" in the prompt when the block fires (from a bash command or a URL tool), or add it manually:
133
138
  ```json
134
139
  // ~/.pi/agent/extensions/sandbox.json (global)
135
140
  // or <cwd>/.pi/sandbox.json (project-local)
package/src/core/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Both layer adapters and the unit tests import from here.
4
4
  */
5
5
  export * from "./policy/defaults";
6
+ export * from "./policy/domains";
6
7
  export * from "./policy/default-file";
7
8
  export * from "./policy/files";
8
9
  export * from "./policy/merge";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Domain pattern matching shared by both layers. Pure: no pi, no OS.
3
+ *
4
+ * `hostnameOf` parses a URL to its lowercase host; `domainMatches` implements
5
+ * the allow-list syntax used by `allowedDomains` / `deniedDomains` / grants:
6
+ * an exact host, or `*.domain` which also matches the apex. Port suffixes
7
+ * (`host:443`) are a sandbox-runtime config feature only; this matcher is
8
+ * hostname-only, which is what both layers store in policy.
9
+ */
10
+
11
+ export function hostnameOf(url: string): string | null {
12
+ try {
13
+ return new URL(url).hostname.toLowerCase();
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function domainMatches(host: string, pattern: string): boolean {
20
+ const p = pattern.toLowerCase();
21
+ if (p.startsWith("*.")) {
22
+ const suffix = p.slice(1); // ".npmjs.org"
23
+ return host === p.slice(2) || host.endsWith(suffix);
24
+ }
25
+ return host === p;
26
+ }
@@ -4,27 +4,63 @@
4
4
 
5
5
  // ---------- Layer 1 violation attribution ----------
6
6
 
7
- const EPERM_RE = /operation not permitted|EPERM|EACCES/i;
7
+ /**
8
+ * Filesystem-refusal evidence in a sandboxed command's output. Linux
9
+ * bubblewrap reports a write outside `allowWrite` as `EROFS: read-only file
10
+ * system`; macOS sandbox-exec reports the same fence as `Operation not
11
+ * permitted`; EACCES covers a permission-denied variant either can print.
12
+ */
13
+ const FS_BLOCKED_RE = /operation not permitted|EPERM|EACCES|EROFS|read-only file system/i;
14
+
15
+ /**
16
+ * `EROFS` can only come from a modification, so it proves the refused access
17
+ * was a write even when the target path also matches a read-deny pattern.
18
+ * EPERM/EACCES are ambiguous about direction.
19
+ */
20
+ const WRITE_BLOCK_RE = /EROFS|read-only file system/i;
21
+
22
+ /** True when the output shows the OS sandbox refused a filesystem access. */
23
+ export function isBlockedAccessError(output: string): boolean {
24
+ return FS_BLOCKED_RE.test(output);
25
+ }
26
+
27
+ /** True when the refused access was a write (EROFS only happens on a modification). */
28
+ export function isWriteBlockError(output: string): boolean {
29
+ return WRITE_BLOCK_RE.test(output);
30
+ }
31
+
32
+ /** A shell-name/empty token is not a path; everything else resolves against cwd unless absolute. */
33
+ function toAbsPath(raw: string, cwd: string, home: string): string | undefined {
34
+ let token = raw.trim().replace(/[.,;]+$/, "");
35
+ if (!token || /^(bash|sh|zsh)$/.test(token)) return undefined;
36
+ if (token === "~" || token.startsWith("~/")) token = home + token.slice(1);
37
+ const abs = token.startsWith("/") ? token : `${cwd.replace(/\/+$/, "")}/${token}`;
38
+ return normalizeAbs(abs);
39
+ }
8
40
 
9
41
  /**
10
42
  * The path a sandboxed bash command was refused, from its error output, as
11
- * an absolute path. Handles `tool: ./rel: Operation not permitted`,
12
- * quoted paths (`cannot touch 'x.pem'`), `~/…` and absolute paths. Relative
13
- * paths resolve against the command's working directory, never against `/`.
43
+ * an absolute path. Handles `tool: ./rel: Operation not permitted`, quoted
44
+ * paths (`cannot touch 'x.pem'`), `~/…` and absolute paths, plus the
45
+ * errno-style forms that put the path *after* the error text
46
+ * (`EROFS: read-only file system, open '/abs/path'`,
47
+ * `[Errno 30] Read-only file system: '/abs/path'`). Relative paths resolve
48
+ * against the command's working directory, never against `/`.
14
49
  */
15
50
  export function extractBlockedPath(output: string, cwd: string, home: string): string | undefined {
16
51
  for (const line of output.split("\n").reverse()) {
17
- const m = EPERM_RE.exec(line);
52
+ const m = FS_BLOCKED_RE.exec(line);
18
53
  if (!m) continue;
54
+ // errno-style messages (Node, Python, coreutils) put the operand in
55
+ // quotes, usually after the error token: take the last quoted token.
56
+ const quoted = [...line.matchAll(/['"“‘`]([^'"”’`]+)['"”’`]/g)].map((q) => q[1] as string);
57
+ const quotedPath = quoted.length ? toAbsPath(quoted[quoted.length - 1] as string, cwd, home) : undefined;
58
+ if (quotedPath) return quotedPath;
19
59
  // The segment right before the error text, e.g. "grep: ./.env: Operation…" → "./.env".
20
60
  const before = line.slice(0, m.index).replace(/[\s:]+$/, "");
21
61
  const segment = before.split(/:\s+/).pop() ?? "";
22
- const quoted = /['"“‘`]([^'"”’`]+)['"”’`]\s*$/.exec(segment);
23
- let token = (quoted?.[1] ?? segment.split(/\s+/).pop() ?? "").trim();
24
- if (!token || /^(bash|sh|zsh)$/.test(token)) continue;
25
- if (token === "~" || token.startsWith("~/")) token = home + token.slice(1);
26
- const abs = token.startsWith("/") ? token : `${cwd.replace(/\/+$/, "")}/${token}`;
27
- return normalizeAbs(abs);
62
+ const token = toAbsPath(segment.split(/\s+/).pop() ?? "", cwd, home);
63
+ if (token) return token;
28
64
  }
29
65
  return undefined;
30
66
  }
@@ -9,10 +9,16 @@ 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, isSafeFolderGrant, matchedAskCommands, matchesPolicyPattern, outsideProjectMode, outsideProjectReadCandidates, outsideProjectReadDenied, sandboxFilesystem } from "../core/index";
12
+ import { askReadCandidates, extractBlockedPath, isBlockedAccessError, isSafeFolderGrant, isWriteBlockError, matchedAskCommands, matchesPolicyPattern, outsideProjectMode, outsideProjectReadCandidates, outsideProjectReadDenied, sandboxFilesystem } from "../core/index";
13
13
  import { audit } from "../shared/audit";
14
14
  import { askMain, askRememberFile, type AskCtx } from "../ui/ask-flow";
15
- import { loadConfig } from "./config";
15
+ import { loadConfig, projectTrusted } from "./config";
16
+
17
+ /** First whitespace-separated token of a bash command — used to name the binary in prompt titles. */
18
+ function firstWord(command: string): string {
19
+ return command.trim().split(/\s+/)[0] ?? command;
20
+ }
21
+ import { beginNetworkCommand, endNetworkCommand } from "./network-ask";
16
22
 
17
23
  export interface SandboxedBashOpts {
18
24
  ctx?: { cwd: string; hasUI?: boolean; ui?: { select?: (t: string, o: string[], op?: { timeout?: number }) => Promise<string | undefined>; notify?: (m: string, l?: string) => void } };
@@ -42,8 +48,12 @@ async function preflightSensitiveCommands(
42
48
  const matched = matchedAskCommands(command, ask, cwd, homedir());
43
49
  if (!matched.length) return undefined;
44
50
  const ui = opts.ctx as AskCtx;
45
- const body = ` command: ${matched.join(", ")}\n why: it can print environment values (API tokens)`;
46
- const choice = await askMain(ui, "Command may print secrets", body, { once: true, remember: false });
51
+ const body = [
52
+ `Command ${matched.join(", ")}`,
53
+ `Risk environment variables often hold API keys and tokens`,
54
+ `Note can only be allowed one run at a time`,
55
+ ].join("\n");
56
+ const choice = await askMain(ui, `Let ${matched[0]} print your environment variables?`, body, { once: true, remember: false, icon: "cred" });
47
57
  const subject = matched.join(", ");
48
58
  if (choice === "once") {
49
59
  auditL1({ subject, decision: "once", note: "sensitive-command", cwd });
@@ -75,7 +85,7 @@ async function preflightAskReads(
75
85
  onData(Buffer.from(`\n❌ pi-sandbox: sensitive file read blocked: ${absPath}. Nothing was run — ask the user.\n`));
76
86
  return { exitCode: 1 };
77
87
  }
78
- const choice = await askMain(ui, "Sensitive file read", ` file: ${absPath}\n why: it may hold secrets`, { remember: false });
88
+ const choice = await askMain(ui, "Let this command read your .env file?", [`File ${absPath}`, `Why .env files usually hold passwords and API keys`, `Risk the AI will see any keys in it`, `Note can't be saved as a permanent rule (it holds secrets)`].join("\n"), { remember: false, icon: "cred" });
79
89
  if (choice === "once" || choice === "session") {
80
90
  auditL1({ subject: absPath, decision: choice, note: "ask-read", cwd });
81
91
  continue;
@@ -100,7 +110,7 @@ async function preflightOutsideReads(
100
110
  const once: string[] = [];
101
111
  for (const absPath of outsideProjectReadCandidates(command, cwd, homedir(), cfg)) {
102
112
  const ui = opts.ctx as AskCtx;
103
- const main = await askMain(ui, "Read outside the project", ` path: ${absPath}\n source: this bash command`);
113
+ const main = await askMain(ui, "Let this command read a file outside your project?", [`File ${absPath}`, `Command ${command}`, `Why files outside your project need your OK`].join("\n"), { session: true });
104
114
  if (main === "once") {
105
115
  auditL1({ subject: absPath, decision: "once", reason: "outside-project-read", note: "preflight", cwd });
106
116
  once.push(absPath);
@@ -111,7 +121,7 @@ async function preflightOutsideReads(
111
121
  if (main === "remember") {
112
122
  const parent = dirname(absPath);
113
123
  const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
114
- const picked = await askRememberFile(ui, "Remember this read?", ` file: ${absPath}\n folder: ${parent}`, absPath, folder);
124
+ const picked = await askRememberFile(ui, "Always allow reading \u2014 what, and where?", [`File ${absPath}`, `Folder ${parent}/`, `Note the rule works for any command, not just ${firstWord(command)}`].join("\n"), absPath, folder, { icon: "save", untrusted: !projectTrusted(cwd) });
115
125
  if (picked) {
116
126
  subject = picked.folder ? parent : absPath;
117
127
  scope = picked.scope;
@@ -239,13 +249,15 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
239
249
 
240
250
  let offending: string | undefined;
241
251
  let readDenied = false;
252
+ let writeDenied = false;
242
253
  let hardDenied = false;
243
254
  let hardWhy = "";
244
255
  let outsideDenied = false;
245
256
  let outsideMode: "allow" | "ask" | "deny" = "allow";
246
- if (/operation not permitted|EPERM|EACCES/i.test(outputTail)) {
257
+ if (isBlockedAccessError(outputTail)) {
247
258
  // Relative paths resolve against the command's cwd: "./.env" is not "/.env".
248
259
  offending = extractBlockedPath(outputTail, cwd, homedir());
260
+ writeDenied = isWriteBlockError(outputTail);
249
261
  // A hard denyRead path (secret material): a write grant would not help,
250
262
  // and "always" is not offered. The outside-project fence (ADR-014) is
251
263
  // ask-able, so keep the two reasons apart.
@@ -255,7 +267,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
255
267
  const hardPat = fsCfg.denyRead.find((pat) => matchesPolicyPattern(offending as string, pat, cwd, homedir()));
256
268
  hardDenied = hardPat !== undefined;
257
269
  hardWhy = hardPat ? `denyRead matched "${hardPat}"` : "denyRead";
258
- outsideDenied = outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
270
+ outsideDenied = !writeDenied && outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
259
271
  outsideMode = outsideProjectMode(fsCfg);
260
272
  }
261
273
  readDenied = hardDenied || outsideDenied;
@@ -265,7 +277,8 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
265
277
  let hint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
266
278
  if (offending) {
267
279
  let why = "";
268
- if (hardDenied) why = " (denyRead: reading it is blocked by policy)";
280
+ if (writeDenied && !hardDenied) why = " (not under an allowWrite root: writes outside it are read-only)";
281
+ else if (hardDenied) why = " (denyRead: reading it is blocked by policy)";
269
282
  else if (outsideDenied) why = " (outside the project: reading it is gated by policy)";
270
283
  hint += ` Path: ${offending}${why}\n`;
271
284
  }
@@ -299,11 +312,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
299
312
  const absPath = offending;
300
313
  try {
301
314
  const ui = opts.ctx as AskCtx;
302
- const main = await askMain(ui, "Read blocked by policy", ` path: ${absPath}\n why: outside the project\n layer: bash sandbox`, { once: false });
315
+ const main = await askMain(ui, "Let this command read a file outside your project?", [`File ${absPath}`, `Command ${command}`, `Why outside your project, not on your allowed list`].join("\n"), { session: true });
303
316
  if (main === "remember") {
304
317
  const parent = dirname(absPath);
305
318
  const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
306
- const picked = await askRememberFile(ui, "Remember this read?", ` file: ${absPath}\n folder: ${parent}`, absPath, folder);
319
+ const picked = await askRememberFile(ui, "Always allow reading \u2014 what, and where?", [`File ${absPath}`, `Folder ${parent}/`, `Note the rule works for any command, not just ${firstWord(command)}`].join("\n"), absPath, folder, { icon: "save", untrusted: !projectTrusted(cwd) });
307
320
  if (picked) {
308
321
  const subject = picked.folder ? parent : absPath;
309
322
  try {
@@ -327,9 +340,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
327
340
  onData(Buffer.from(decisionHint));
328
341
  } else if (offending && readDenied) {
329
342
  const why = hardDenied ? hardWhy : "outside the project";
330
- auditL1({ subject: offending, decision: "read-denied", reason: why, cwd });
331
- opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a read of ${offending} (${why}). Edit sandbox.json if that is wrong.`, "warning");
332
- decisionHint = `\n${blockedLine("Read", why, "read")}\n`;
343
+ const action: "Read" | "Write" = writeDenied ? "Write" : "Read";
344
+ const outcome = action === "Write" ? "written" : "read";
345
+ auditL1({ subject: offending, decision: readDenied && !writeDenied ? "read-denied" : "write-denied", reason: why, cwd });
346
+ opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a ${action.toLowerCase()} of ${offending} (${why}). Edit sandbox.json if that is wrong.`, "warning");
347
+ decisionHint = `\n${blockedLine(action, why, outcome)}\n`;
333
348
  onData(Buffer.from(decisionHint));
334
349
  } else if (offending && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways) {
335
350
  const absPath = offending;
@@ -337,7 +352,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
337
352
  const alreadyGranted = writeOnce.includes(parentDir);
338
353
  try {
339
354
  const ui = opts.ctx as AskCtx;
340
- const main = await askMain(ui, "Write blocked by policy", ` path: ${absPath}\n why: not under any allowWrite root\n layer: bash sandbox`, { once: !alreadyGranted });
355
+ const main = await askMain(ui, `Let ${firstWord(command)} save files in ${parentDir}/?`, [`File ${absPath}`, `Command ${command}`, `Why outside your project, not on your allowed list`].join("\n"), { session: true, once: !alreadyGranted });
341
356
  if (main === "once") {
342
357
  auditL1({ subject: absPath, granularity: "folder", original: absPath, decision: "once", scope: "invocation", cwd: opts.ctx.cwd, note: "write-once" });
343
358
  opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${absPath} once — re-running the command`, "warning");
@@ -347,7 +362,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
347
362
  }
348
363
  if (main === "remember") {
349
364
  const folder = isSafeFolderGrant(parentDir, homedir()) ? parentDir : null;
350
- const picked = await askRememberFile(ui, "Remember this write?", ` file: ${absPath}\n folder: ${parentDir}`, absPath, folder);
365
+ const picked = await askRememberFile(ui, "Always allow saving \u2014 what, and where?", [`File ${absPath}`, `Folder ${parentDir}/`, `Note the rule works for any command, not just ${firstWord(command)}`].join("\n"), absPath, folder, { icon: "save", untrusted: !projectTrusted(cwd) });
351
366
  if (picked) {
352
367
  const subject = picked.folder ? parentDir : absPath;
353
368
  try {
@@ -370,6 +385,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
370
385
  decisionHint = `\n${blockedLine("Write", "user denied", "written")}\n`;
371
386
  }
372
387
  onData(Buffer.from(decisionHint));
388
+ } else if (offending && writeDenied) {
389
+ // No UI to prompt with: report the fence and the path to the model.
390
+ auditL1({ subject: offending, decision: "write-denied", reason: "not under any allowWrite root", cwd });
391
+ opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a write of ${offending} (not under any allowWrite root).`, "warning");
392
+ onData(Buffer.from(`\n${blockedLine("Write", "not under any allowWrite root", "written")}\n`));
373
393
  }
374
394
 
375
395
  if (signal?.aborted) {
@@ -383,7 +403,14 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
383
403
  });
384
404
  };
385
405
 
386
- return attempt([]);
406
+ // "Allow once" network grants cover this command's connections to a host;
407
+ // the finally releases them however the command ends (abort/timeout too).
408
+ beginNetworkCommand();
409
+ try {
410
+ return await attempt([]);
411
+ } finally {
412
+ endNetworkCommand();
413
+ }
387
414
  },
388
415
  };
389
416
  }