auto-permission-system 3.0.1 → 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. 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
+ `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.1",
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
 
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.
88
+
86
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.
87
90
 
88
- Every prompt waits **10 seconds** by default; no answer means the safe default: **block / deny**. Esc on any screen also blocks.
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
+ }
@@ -12,7 +12,13 @@ import { type BashOperations } from "@earendil-works/pi-coding-agent";
12
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;
@@ -302,11 +312,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
302
312
  const absPath = offending;
303
313
  try {
304
314
  const ui = opts.ctx as AskCtx;
305
- 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 });
306
316
  if (main === "remember") {
307
317
  const parent = dirname(absPath);
308
318
  const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
309
- 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) });
310
320
  if (picked) {
311
321
  const subject = picked.folder ? parent : absPath;
312
322
  try {
@@ -342,7 +352,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
342
352
  const alreadyGranted = writeOnce.includes(parentDir);
343
353
  try {
344
354
  const ui = opts.ctx as AskCtx;
345
- 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 });
346
356
  if (main === "once") {
347
357
  auditL1({ subject: absPath, granularity: "folder", original: absPath, decision: "once", scope: "invocation", cwd: opts.ctx.cwd, note: "write-once" });
348
358
  opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${absPath} once — re-running the command`, "warning");
@@ -352,7 +362,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
352
362
  }
353
363
  if (main === "remember") {
354
364
  const folder = isSafeFolderGrant(parentDir, homedir()) ? parentDir : null;
355
- 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) });
356
366
  if (picked) {
357
367
  const subject = picked.folder ? parentDir : absPath;
358
368
  try {
@@ -393,7 +403,14 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
393
403
  });
394
404
  };
395
405
 
396
- 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
+ }
397
414
  },
398
415
  };
399
416
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Layer 1 sandbox-runtime lifecycle: initialize, reset/reload, and persist an
3
- * ask-tier "always" grant into sandbox.json.
2
+ * Layer 1 sandbox-runtime lifecycle: initialize, reset/reload, persist an
3
+ * ask-tier "always" grant into sandbox.json, and apply network grants live.
4
4
  *
5
5
  * Adapter module: owns the SandboxManager singleton and the on-disk policy.
6
6
  */
@@ -8,13 +8,15 @@
8
8
  import { mkdirSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
- import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
11
+ import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
12
12
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
13
  import { readPolicyForUpdate, recordProjectTrust, sandboxFilesystem } from "../core/index";
14
14
  import { loadConfig, projectTrusted, TRUST_STORE, type SandboxConfig } from "./config";
15
+ import { createNetworkAsk, type NetworkAskDeps } from "./network-ask";
15
16
 
16
- export async function initSandbox(cwd: string, config: SandboxConfig): Promise<void> {
17
- await SandboxManager.initialize({
17
+ /** The sandbox-runtime view of a SandboxConfig (pi-only fields stripped). */
18
+ function runtimeConfig(cwd: string, config: SandboxConfig): SandboxRuntimeConfig {
19
+ return {
18
20
  network: config.network,
19
21
  // Strip pi-only fields (modelDenyRead, _comment_*) so SandboxManager
20
22
  // doesn't see keys it doesn't understand; modelDenyRead is enforced by
@@ -25,7 +27,16 @@ export async function initSandbox(cwd: string, config: SandboxConfig): Promise<v
25
27
  : { denyRead: [], allowRead: [], allowWrite: [], denyWrite: [], disabled: true },
26
28
  ignoreViolations: config.ignoreViolations,
27
29
  enableWeakerNestedSandbox: config.enableWeakerNestedSandbox,
28
- });
30
+ };
31
+ }
32
+
33
+ /**
34
+ * `deps` registers the unknown-domain ask callback (ADR-023). Without it the
35
+ * proxy hard-denies hosts outside `allowedDomains` (sandbox-runtime's own
36
+ * behavior when no callback is registered).
37
+ */
38
+ export async function initSandbox(cwd: string, config: SandboxConfig, deps?: NetworkAskDeps): Promise<void> {
39
+ await SandboxManager.initialize(runtimeConfig(cwd, config), deps ? createNetworkAsk(deps) : undefined);
29
40
  }
30
41
 
31
42
  export async function resetSandbox(): Promise<void> {
@@ -36,17 +47,35 @@ export async function resetSandbox(): Promise<void> {
36
47
  }
37
48
  }
38
49
 
39
- export async function reloadSandbox(cwd: string): Promise<void> {
50
+ export async function reloadSandbox(cwd: string, deps?: NetworkAskDeps): Promise<void> {
40
51
  const config = loadConfig(cwd);
41
52
  await resetSandbox();
42
- await initSandbox(cwd, config);
53
+ await initSandbox(cwd, config, deps);
43
54
  }
44
55
 
45
- /** Persist an "always" Layer 1 override (scope: cwd or global) and live-reload SandboxManager. */
46
- export async function persistLayer1Override(
56
+ /**
57
+ * Add a host to the running proxy's allowlist in place. `updateConfig` swaps
58
+ * the config the proxy reads per request; a `reset()`/`initialize()` here
59
+ * would tear down the proxy that is serving the request being approved.
60
+ */
61
+ export function applyNetworkGrant(host: string): void {
62
+ const current = SandboxManager.getConfig();
63
+ if (!current) return;
64
+ const pattern = host.toLowerCase();
65
+ if (current.network.allowedDomains.some((p) => p.toLowerCase() === pattern)) return;
66
+ SandboxManager.updateConfig({
67
+ ...current,
68
+ network: { ...current.network, allowedDomains: [...current.network.allowedDomains, pattern] },
69
+ });
70
+ }
71
+
72
+ type Layer1OverrideKind = "allowWrite" | "allowRead" | "allowDomains";
73
+
74
+ /** File write only — the caller decides whether (and how) to apply it live. */
75
+ async function writeLayer1Override(
47
76
  cwd: string,
48
- kind: "allowWrite" | "allowRead",
49
- absPath: string,
77
+ kind: Layer1OverrideKind,
78
+ value: string,
50
79
  scope: "cwd" | "global",
51
80
  ): Promise<string> {
52
81
  const { dir, path } =
@@ -59,15 +88,35 @@ export async function persistLayer1Override(
59
88
  throw new Error(`${path} is not trusted; run /security trust first, or choose an "ALL projects" option`);
60
89
  }
61
90
  // Throws on an unparseable file: never overwrite a hand-written policy we could not read.
62
- const existing = readPolicyForUpdate(path) as { overrides?: { allowWrite?: string[]; allowRead?: string[] } };
91
+ const existing = readPolicyForUpdate(path) as { overrides?: Partial<Record<Layer1OverrideKind, string[]>> };
63
92
  const overrides = existing.overrides ?? {};
64
93
  const list = overrides[kind] ?? [];
65
- if (!list.includes(absPath)) list.push(absPath);
94
+ if (!list.includes(value)) list.push(value);
66
95
  overrides[kind] = list;
67
96
  existing.overrides = overrides;
68
97
  mkdirSync(dir, { recursive: true });
69
98
  writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`);
70
99
  if (scope === "cwd") recordProjectTrust(path, TRUST_STORE);
71
- await reloadSandbox(cwd);
100
+ return path;
101
+ }
102
+
103
+ /**
104
+ * Write an "always" domain grant (file only). The network ask applies it live
105
+ * with `applyNetworkGrant` right after, so the in-flight request survives.
106
+ */
107
+ export async function writeNetworkOverride(cwd: string, host: string, scope: "cwd" | "global"): Promise<string> {
108
+ return writeLayer1Override(cwd, "allowDomains", host, scope);
109
+ }
110
+
111
+ /** Persist an "always" Layer 1 override (scope: cwd or global) and live-reload SandboxManager. */
112
+ export async function persistLayer1Override(
113
+ cwd: string,
114
+ kind: "allowWrite" | "allowRead",
115
+ absPath: string,
116
+ scope: "cwd" | "global",
117
+ deps?: NetworkAskDeps,
118
+ ): Promise<string> {
119
+ const path = await writeLayer1Override(cwd, kind, absPath, scope);
120
+ await reloadSandbox(cwd, deps);
72
121
  return path;
73
122
  }