auto-permission-system 2.20.0 → 3.0.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
@@ -123,7 +123,7 @@ Never asked about: the project itself, your `allowWrite` roots, pi's own package
123
123
 
124
124
  ### Ask-tier prompt
125
125
 
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).
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
127
 
128
128
  Every prompt waits **10 seconds** by default. If you do not answer, it resolves to the safe default: **block / deny**.
129
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-permission-system",
3
- "version": "2.20.0",
3
+ "version": "3.0.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "enabled": true,
3
- "mode": "default",
3
+ "mode": "advanced-secure",
4
4
  "network": {
5
5
  "allowedDomains": [
6
6
  "registry.npmjs.org",
@@ -29,74 +29,58 @@
29
29
  },
30
30
  "filesystem": {
31
31
  "outsideProject": {
32
- "read": "ask",
33
- "allowRead": [
34
- "~/.pi/agent/install",
35
- "~/.pi/agent/npm",
36
- "~/Capture",
37
- "~/.nvm",
38
- "~/.pyenv",
39
- "~/.cargo/registry",
40
- "~/go/pkg",
41
- "~/.rustup",
42
- "~/.pnpm-store",
43
- "~/.local/share/pnpm",
44
- "~/.bun",
45
- "~/.cache/pip"
46
- ]
32
+ "read": "allow",
33
+ "allowRead": []
47
34
  },
48
- "denyRead": [
49
- "~/.ssh",
50
- "~/.gnupg",
51
- "~/.aws",
52
- "~/.kube",
53
- "~/.docker",
54
- "~/.claude",
55
- "~/.codex",
56
- "~/.gemini",
57
- "~/.composio",
58
- "~/.pi/agent/auth.json",
59
- "~/.pi/agent/mcp.json",
60
- "~/.pi/agent/mcp-auth.json",
61
- "~/.pi/agent/trust.json",
62
- "~/.pi/agent/models-store.json",
63
- "~/.pi/agent/audit.log",
64
- "~/.pi/agent/sessions",
65
- "~/.pi/sessions",
66
- "~/Downloads",
67
- "~/Desktop",
68
- "~/Documents",
69
- "~/.bash_history",
70
- "~/.zsh_history",
71
- "~/.history"
72
- ],
35
+ "denyRead": [],
73
36
  "askRead": [
74
37
  ".env",
75
- ".env.*"
76
- ],
77
- "modelDenyRead": [
78
- "~/.config/gh/hosts.yml",
79
- "~/.netrc",
80
- "~/.aws/credentials",
81
- "~/.aws/config",
82
- "~/.kube/config",
83
- "~/.docker/config.json",
84
- "~/.npmrc",
85
- "~/.pypirc",
86
- "~/.cargo/credentials.toml",
87
- "~/.gitconfig"
38
+ ".env.*",
39
+ "*.pem",
40
+ "*.key",
41
+ "*.p12",
42
+ "*.pfx",
43
+ "*.keystore",
44
+ "*.ppk",
45
+ "id_rsa",
46
+ "id_rsa.*",
47
+ "id_ed25519",
48
+ "id_ed25519.*",
49
+ "id_ecdsa",
50
+ "id_ecdsa.*",
51
+ ".netrc",
52
+ ".npmrc",
53
+ ".pypirc",
54
+ ".gitconfig",
55
+ ".git-credentials",
56
+ "credentials",
57
+ "*.credentials",
58
+ "credentials.toml",
59
+ "hosts.yml",
60
+ "auth.json",
61
+ "mcp.json",
62
+ "mcp-auth.json",
63
+ "models-store.json",
64
+ "trust.json",
65
+ "audit.log",
66
+ "sessions"
88
67
  ],
68
+ "modelDenyRead": [],
89
69
  "allowWrite": [
90
70
  ".",
91
71
  "/tmp"
92
72
  ],
93
- "denyWrite": [
73
+ "denyWrite": [],
74
+ "askWrite": [
94
75
  ".env",
95
76
  ".env.*",
96
77
  "*.pem",
97
78
  "*.key",
98
79
  "*.p12",
99
80
  "*.pfx",
81
+ "auth.json",
82
+ "mcp.json",
83
+ "mcp-auth.json",
100
84
  "~/.gnupg",
101
85
  "~/.ssh/**",
102
86
  "~/.pi/agent/extensions",
@@ -119,7 +103,11 @@
119
103
  "network": "allow"
120
104
  },
121
105
  "commands": {
122
- "ask": ["printenv", "env", "/proc/*/environ"]
106
+ "ask": [
107
+ "printenv",
108
+ "env",
109
+ "/proc/*/environ"
110
+ ]
123
111
  },
124
112
  "overrides": {
125
113
  "allowRead": [],
@@ -3,6 +3,9 @@
3
3
 
4
4
  "enabled": true,
5
5
 
6
+ "mode": "advanced-secure",
7
+ "_comment_mode": "\"default\" = rules only. \"advanced-secure\" adds secret/credential detection. An untrusted project may move this UP (stricter), never down.",
8
+
6
9
  "network": {
7
10
  "_comment_allowedDomains": "Domains that fetch_content / get_search_content may reach. Empty list = allow all.",
8
11
  "allowedDomains": [
@@ -29,34 +32,48 @@
29
32
  },
30
33
 
31
34
  "filesystem": {
32
- "_comment_denyRead": "Layer 1 (bash) AND Layer 2 (model read tool). Neither subprocesses nor the model read tool may access these.",
33
- "_comment_outsideProject": "Reads outside the working directory. read: allow (default) | ask | deny. Never asked: the project, allowWrite roots, ~/.pi/agent, pi's own package, allowRead. Interactive: a read of a full path you named in your own message runs without a prompt. See ADR-012.",
34
- "outsideProject": { "read": "ask", "allowRead": [] },
35
+ "_comment_outsideProject": "Reads outside the working directory. read: allow (default) | ask | deny. allowRead = roots that never ask. IMPORTANT: askRead below is NOT scoped to this block — it applies both inside and outside the project.",
36
+ "outsideProject": { "read": "allow", "allowRead": [] },
35
37
 
36
- "denyRead": [
37
- "~/.ssh",
38
- "~/.gnupg"
39
- ],
38
+ "_comment_denyRead": "Layer 1 (bash) AND Layer 2 (model read tool): hard block, no prompt, no 'always'. Empty by default — secrets ask via askRead instead. Add a path here to hard-deny it.",
39
+ "denyRead": [],
40
40
 
41
- "_comment_askRead": "Layer 1 (bash pre-flight) AND Layer 2 (model read tool): ask before reading these instead of hard-denying them. Put a path in denyRead to deny it outright. See ADR-019.",
41
+ "_comment_askRead": "Layer 1 (bash pre-flight) AND Layer 2 (model read tool): ask before reading these, INSIDE and OUTSIDE the project. An entry without '/' matches the file name anywhere ('.env' matches <cwd>/.env and ~/x/.env); an entry with '/' or '~/' is a path pattern. denyRead wins over askRead. See ADR-019.",
42
42
  "askRead": [
43
43
  ".env",
44
- ".env.*"
44
+ ".env.*",
45
+ "*.pem",
46
+ "*.key",
47
+ "*.p12",
48
+ "*.pfx",
49
+ "*.keystore",
50
+ "*.ppk",
51
+ "id_rsa",
52
+ "id_rsa.*",
53
+ "id_ed25519",
54
+ "id_ed25519.*",
55
+ "id_ecdsa",
56
+ "id_ecdsa.*",
57
+ ".netrc",
58
+ ".npmrc",
59
+ ".pypirc",
60
+ ".gitconfig",
61
+ ".git-credentials",
62
+ "credentials",
63
+ "*.credentials",
64
+ "credentials.toml",
65
+ "hosts.yml",
66
+ "auth.json",
67
+ "mcp.json",
68
+ "mcp-auth.json",
69
+ "models-store.json",
70
+ "trust.json",
71
+ "audit.log",
72
+ "sessions"
45
73
  ],
46
74
 
47
- "_comment_modelDenyRead": "Layer 2 ONLY. Token/credential files inside dirs that CLI tools (gh, aws, kubectl, docker) need to read. The model's `read` tool is blocked; subprocesses still work. Caveat: the model could `bash cat` these — `denyRead` is the hard block for that.",
48
- "modelDenyRead": [
49
- "~/.config/gh/hosts.yml",
50
- "~/.netrc",
51
- "~/.aws/credentials",
52
- "~/.aws/config",
53
- "~/.kube/config",
54
- "~/.docker/config.json",
55
- "~/.npmrc",
56
- "~/.pypirc",
57
- "~/.cargo/credentials.toml",
58
- "~/.gitconfig"
59
- ],
75
+ "_comment_modelDenyRead": "Layer 2 ONLY: hard block for the model's read tool while subprocesses (gh, aws, kubectl, docker) still work. Empty by default — the askRead names cover the model too.",
76
+ "modelDenyRead": [],
60
77
 
61
78
  "_comment_allowWrite": "Layer 2: the model's write/edit tools may only write inside these roots. '.' = cwd.",
62
79
  "allowWrite": [
@@ -64,17 +81,36 @@
64
81
  "/tmp"
65
82
  ],
66
83
 
67
- "_comment_denyWrite": "Layer 1 + Layer 2: writes always blocked regardless of allowWrite.",
68
- "denyWrite": [
84
+ "_comment_denyWrite": "Layer 1 + Layer 2: hard block, no prompt. Empty by default — sensitive writes ask via askWrite instead. Add a path here to hard-deny writes to it.",
85
+ "denyWrite": [],
86
+
87
+ "_comment_askWrite": "Layer 1 + Layer 2: ask before writing these, inside and outside allowWrite. Same matching as askRead: no '/' means file name anywhere. denyWrite wins over askWrite.",
88
+ "askWrite": [
69
89
  ".env",
70
90
  ".env.*",
71
91
  "*.pem",
72
92
  "*.key",
73
93
  "*.p12",
74
94
  "*.pfx",
95
+ "auth.json",
96
+ "mcp.json",
97
+ "mcp-auth.json",
75
98
  "~/.gnupg",
76
99
  "~/.ssh/**",
77
- "~/.pi/agent/extensions/**"
100
+ "~/.pi/agent/extensions",
101
+ "~/.pi/agent/extensions/**",
102
+ "~/.pi/agent/auth.json",
103
+ "~/.pi/agent/mcp.json",
104
+ "~/.pi/agent/mcp-auth.json",
105
+ "~/.pi/agent/trust.json",
106
+ "~/.pi/agent/models-store.json",
107
+ "~/.pi/agent/audit.log",
108
+ "~/.pi/agent/sessions",
109
+ "~/.pi/sessions",
110
+ "~/.bashrc",
111
+ "~/.zshrc",
112
+ "~/.profile",
113
+ "~/.bash_profile"
78
114
  ]
79
115
  },
80
116
 
@@ -83,6 +83,8 @@ When a normal (non-absolute-deny) call is blocked:
83
83
  - **always for ALL projects** — whitelist in `~/.pi/agent/extensions/sandbox.json`
84
84
  - **always for ALL projects (folder)** — whitelist parent directory globally
85
85
 
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
+
86
88
  Every prompt waits **10 seconds** by default; no answer means the safe default: **block / deny**. Esc on any screen also blocks.
87
89
 
88
90
  ## Sensitive reads ask
@@ -157,38 +157,69 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
157
157
  // "this once" grants apply to this one invocation only: hand them to
158
158
  // wrapWithSandbox as a customConfig, so nothing is persisted and the
159
159
  // session-wide sandbox is not re-initialised.
160
- let custom: Partial<SandboxRuntimeConfig> | undefined;
161
- if (pre?.once.length) {
160
+ const readOnce = pre?.once ?? [];
161
+ const buildCustom = (writeOnce: string[]): Partial<SandboxRuntimeConfig> | undefined => {
162
+ if (!readOnce.length && !writeOnce.length) return undefined;
162
163
  const fresh = loadConfig(cwd).filesystem ?? { denyRead: [], allowWrite: [], denyWrite: [] };
163
- custom = {
164
+ return {
164
165
  filesystem: sandboxFilesystem(
165
- { ...fresh, allowRead: [...(fresh.allowRead ?? []), ...pre.once] },
166
+ { ...fresh, allowRead: [...(fresh.allowRead ?? []), ...readOnce], allowWrite: [...fresh.allowWrite, ...writeOnce] },
166
167
  { cwd, home: homedir() },
167
168
  ),
168
169
  };
169
- }
170
-
171
- const wrappedCommand = await SandboxManager.wrapWithSandbox(command, undefined, custom);
170
+ };
172
171
 
173
172
  const uid = process.getuid?.() ?? 0;
174
173
  // macOS has /private/tmp; Linux only /tmp. Either sits in the default
175
174
  // allowWrite list, so the sandboxed command can write its scratch files.
176
175
  const piTmp = `${existsSync("/private/tmp") ? "/private/tmp" : "/tmp"}/pi-${uid}`;
177
176
 
178
- return new Promise((resolve, reject) => {
179
- const child = spawn("bash", ["-c", wrappedCommand], {
180
- cwd,
181
- detached: true,
182
- stdio: ["ignore", "pipe", "pipe"],
183
- env: { ...process.env, TMPDIR: `${piTmp}/` },
184
- });
177
+ // One one-time write grant = one re-run of the same command with that path
178
+ // added to allowWrite, never persisted (mirrors the read pre-flight's
179
+ // customConfig). A path already granted is not offered again, so a command
180
+ // that keeps hitting the same fence cannot loop.
181
+ const attempt = async (writeOnce: string[]): Promise<{ exitCode: number | null }> => {
182
+ const wrappedCommand = await SandboxManager.wrapWithSandbox(command, undefined, buildCustom(writeOnce));
185
183
 
186
- let timedOut = false;
187
- let timeoutHandle: NodeJS.Timeout | undefined;
184
+ return new Promise((resolve, reject) => {
185
+ const child = spawn("bash", ["-c", wrappedCommand], {
186
+ cwd,
187
+ detached: true,
188
+ stdio: ["ignore", "pipe", "pipe"],
189
+ env: { ...process.env, TMPDIR: `${piTmp}/` },
190
+ });
188
191
 
189
- if (timeout !== undefined && timeout > 0) {
190
- timeoutHandle = setTimeout(() => {
191
- timedOut = true;
192
+ let timedOut = false;
193
+ let timeoutHandle: NodeJS.Timeout | undefined;
194
+
195
+ if (timeout !== undefined && timeout > 0) {
196
+ timeoutHandle = setTimeout(() => {
197
+ timedOut = true;
198
+ if (child.pid) {
199
+ try {
200
+ process.kill(-child.pid, "SIGKILL");
201
+ } catch {
202
+ child.kill("SIGKILL");
203
+ }
204
+ }
205
+ }, timeout * 1000);
206
+ }
207
+
208
+ let outputTail = "";
209
+ const captureOut = (chunk: Buffer | string) => {
210
+ outputTail = (outputTail + chunk.toString()).slice(-2048);
211
+ onData(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
212
+ };
213
+ child.stdout?.on("data", captureOut);
214
+ child.stderr?.on("data", captureOut);
215
+
216
+ child.on("error", (err) => {
217
+ if (timeoutHandle) clearTimeout(timeoutHandle);
218
+ SandboxManager.cleanupAfterCommand();
219
+ reject(err);
220
+ });
221
+
222
+ const onAbort = () => {
192
223
  if (child.pid) {
193
224
  try {
194
225
  process.kill(-child.pid, "SIGKILL");
@@ -196,174 +227,163 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
196
227
  child.kill("SIGKILL");
197
228
  }
198
229
  }
199
- }, timeout * 1000);
200
- }
230
+ };
201
231
 
202
- let outputTail = "";
203
- const captureOut = (chunk: Buffer | string) => {
204
- outputTail = (outputTail + chunk.toString()).slice(-2048);
205
- onData(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
206
- };
207
- child.stdout?.on("data", captureOut);
208
- child.stderr?.on("data", captureOut);
232
+ signal?.addEventListener("abort", onAbort, { once: true });
209
233
 
210
- child.on("error", (err) => {
211
- if (timeoutHandle) clearTimeout(timeoutHandle);
212
- reject(err);
213
- });
214
-
215
- const onAbort = () => {
216
- if (child.pid) {
217
- try {
218
- process.kill(-child.pid, "SIGKILL");
219
- } catch {
220
- child.kill("SIGKILL");
221
- }
222
- }
223
- };
234
+ child.on("close", async (code) => {
235
+ if (timeoutHandle) clearTimeout(timeoutHandle);
236
+ signal?.removeEventListener("abort", onAbort);
224
237
 
225
- signal?.addEventListener("abort", onAbort, { once: true });
238
+ SandboxManager.cleanupAfterCommand();
226
239
 
227
- child.on("close", async (code) => {
228
- if (timeoutHandle) clearTimeout(timeoutHandle);
229
- signal?.removeEventListener("abort", onAbort);
230
-
231
- let offending: string | undefined;
232
- let readDenied = false;
233
- let hardDenied = false;
234
- let hardWhy = "";
235
- let outsideDenied = false;
236
- let outsideMode: "allow" | "ask" | "deny" = "allow";
237
- if (/operation not permitted|EPERM|EACCES/i.test(outputTail)) {
238
- // Relative paths resolve against the command's cwd: "./.env" is not "/.env".
239
- offending = extractBlockedPath(outputTail, cwd, homedir());
240
- // A hard denyRead path (secret material): a write grant would not help,
241
- // and "always" is not offered. The outside-project fence (ADR-014) is
242
- // ask-able, so keep the two reasons apart.
243
- if (offending) {
244
- const fsCfg = loadConfig(cwd).filesystem;
245
- if (fsCfg) {
246
- const hardPat = fsCfg.denyRead.find((pat) => matchesPolicyPattern(offending as string, pat, cwd, homedir()));
247
- hardDenied = hardPat !== undefined;
248
- hardWhy = hardPat ? `denyRead matched "${hardPat}"` : "denyRead";
249
- outsideDenied = outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
250
- outsideMode = outsideProjectMode(fsCfg);
240
+ let offending: string | undefined;
241
+ let readDenied = false;
242
+ let hardDenied = false;
243
+ let hardWhy = "";
244
+ let outsideDenied = false;
245
+ let outsideMode: "allow" | "ask" | "deny" = "allow";
246
+ if (/operation not permitted|EPERM|EACCES/i.test(outputTail)) {
247
+ // Relative paths resolve against the command's cwd: "./.env" is not "/.env".
248
+ offending = extractBlockedPath(outputTail, cwd, homedir());
249
+ // A hard denyRead path (secret material): a write grant would not help,
250
+ // and "always" is not offered. The outside-project fence (ADR-014) is
251
+ // ask-able, so keep the two reasons apart.
252
+ if (offending) {
253
+ const fsCfg = loadConfig(cwd).filesystem;
254
+ if (fsCfg) {
255
+ const hardPat = fsCfg.denyRead.find((pat) => matchesPolicyPattern(offending as string, pat, cwd, homedir()));
256
+ hardDenied = hardPat !== undefined;
257
+ hardWhy = hardPat ? `denyRead matched "${hardPat}"` : "denyRead";
258
+ outsideDenied = outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
259
+ outsideMode = outsideProjectMode(fsCfg);
260
+ }
261
+ readDenied = hardDenied || outsideDenied;
251
262
  }
252
- readDenied = hardDenied || outsideDenied;
253
- }
254
- const configDirHint = offending && /\.config\/|\.kube\/|\.docker\/|\.netrc|\.aws\/|\.npmrc|\.gitconfig/.test(offending);
263
+ const configDirHint = offending && /\.config\/|\.kube\/|\.docker\/|\.netrc|\.aws\/|\.npmrc|\.gitconfig/.test(offending);
255
264
 
256
- let hint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
257
- if (offending) {
258
- let why = "";
259
- if (hardDenied) why = " (denyRead: reading it is blocked by policy)";
260
- else if (outsideDenied) why = " (outside the project: reading it is gated by policy)";
261
- hint += ` Path: ${offending}${why}\n`;
262
- }
263
- hint += ` This is the pi sandbox (Layer 1), NOT macOS Full Disk Access / TCC.\n`;
264
- if (configDirHint) {
265
- hint +=
266
- ` Looks like a tool's own config dir. To allow this tool in the\n` +
267
- ` current project, add a project-local policy:\n` +
268
- ` mkdir -p ${cwd}/.pi && cat > ${cwd}/.pi/sandbox.json <<'JSON'\n` +
269
- ` { "filesystem": { "allowWrite": [".", "${offending?.replace(/^~/, "$HOME") ?? "~/.config/<tool>"}"] } }\n` +
270
- ` JSON\n` +
271
- ` Or run pi with --yolo for one-off elevated access (disables ALL layers).\n`;
272
- } else {
273
- hint +=
274
- ` Use $TMPDIR (= ${piTmp}/) for scratch files,\n` +
275
- ` or write inside the project directory (${cwd}).\n`;
276
- }
277
- hint += ` Policy: ~/.pi/agent/extensions/sandbox.json (+ <cwd>/.pi/sandbox.json overrides).\n`;
278
- if (opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways && !readDenied) {
279
- hint += ` → Waiting for your decision in the prompt above before this bash call returns to the model.\n`;
265
+ let hint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
266
+ if (offending) {
267
+ let why = "";
268
+ if (hardDenied) why = " (denyRead: reading it is blocked by policy)";
269
+ else if (outsideDenied) why = " (outside the project: reading it is gated by policy)";
270
+ hint += ` Path: ${offending}${why}\n`;
271
+ }
272
+ hint += ` This is the pi sandbox (Layer 1), NOT macOS Full Disk Access / TCC.\n`;
273
+ if (configDirHint) {
274
+ hint +=
275
+ ` Looks like a tool's own config dir. To allow this tool in the\n` +
276
+ ` current project, add a project-local policy:\n` +
277
+ ` mkdir -p ${cwd}/.pi && cat > ${cwd}/.pi/sandbox.json <<'JSON'\n` +
278
+ ` { "filesystem": { "allowWrite": [".", "${offending?.replace(/^~/, "$HOME") ?? "~/.config/<tool>"}"] } }\n` +
279
+ ` JSON\n` +
280
+ ` Or run pi with --yolo for one-off elevated access (disables ALL layers).\n`;
281
+ } else {
282
+ hint +=
283
+ ` Use $TMPDIR (= ${piTmp}/) for scratch files,\n` +
284
+ ` or write inside the project directory (${cwd}).\n`;
285
+ }
286
+ hint += ` Policy: ~/.pi/agent/extensions/sandbox.json (+ <cwd>/.pi/sandbox.json overrides).\n`;
287
+ if (opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways && !readDenied) {
288
+ hint += ` → Waiting for your decision in the prompt above before this bash call returns to the model.\n`;
289
+ }
290
+ onData(Buffer.from(hint));
280
291
  }
281
- onData(Buffer.from(hint));
282
- }
283
292
 
284
- // Ask-tier prompt: BEFORE resolve so the agent loop pauses while the
285
- // user decides. Otherwise the model gets the EPERM hint immediately,
286
- // tries an alternative, and the prompt sits orphaned in the UI.
287
- let decisionHint = "";
288
- if (offending && outsideDenied && !hardDenied && outsideMode === "ask" && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlwaysRead) {
289
- // ask-tier read grant for a path outside the project (ADR-014).
290
- const absPath = offending;
291
- try {
292
- const ui = opts.ctx as AskCtx;
293
- const main = await askMain(ui, "Read blocked by policy", ` path: ${absPath}\n why: outside the project\n layer: bash sandbox`, { once: false });
294
- if (main === "remember") {
295
- const parent = dirname(absPath);
296
- const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
297
- const picked = await askRememberFile(ui, "Remember this read?", ` file: ${absPath}\n folder: ${parent}`, absPath, folder);
298
- if (picked) {
299
- const subject = picked.folder ? parent : absPath;
300
- try {
301
- const persistedTo = await opts.onAlwaysRead(subject, picked.scope);
302
- auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, decision: picked.scope === "cwd" ? "always-cwd" : "always-global", scope: picked.scope, cwd: opts.ctx.cwd, persisted_to: persistedTo, note: "outside-project-read" });
303
- opts.ctx.ui?.notify?.(`pi-sandbox: allowed read of ${subject} (${picked.scope}${picked.folder ? ", folder" : ""}) — retry the bash command`, "warning");
304
- decisionHint = `\n✅ pi-sandbox: ${subject} now allowed (${picked.scope}${picked.folder ? ", folder" : ""}). Retry the bash command.\n`;
305
- } catch (e) {
306
- auditL1({ subject, scope: picked.scope, cwd: opts.ctx.cwd, error: String(e), note: "outside-project-read" });
307
- decisionHint = `\n❌ pi-sandbox: could not save the permission (${e}). ${subject} remains blocked.\n`;
293
+ // Ask-tier prompt: BEFORE resolve so the agent loop pauses while the
294
+ // user decides. Otherwise the model gets the EPERM hint immediately,
295
+ // tries an alternative, and the prompt sits orphaned in the UI.
296
+ let decisionHint = "";
297
+ if (offending && outsideDenied && !hardDenied && outsideMode === "ask" && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlwaysRead) {
298
+ // ask-tier read grant for a path outside the project (ADR-014).
299
+ const absPath = offending;
300
+ try {
301
+ 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 });
303
+ if (main === "remember") {
304
+ const parent = dirname(absPath);
305
+ const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
306
+ const picked = await askRememberFile(ui, "Remember this read?", ` file: ${absPath}\n folder: ${parent}`, absPath, folder);
307
+ if (picked) {
308
+ const subject = picked.folder ? parent : absPath;
309
+ try {
310
+ const persistedTo = await opts.onAlwaysRead(subject, picked.scope);
311
+ auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, decision: picked.scope === "cwd" ? "always-cwd" : "always-global", scope: picked.scope, cwd: opts.ctx.cwd, persisted_to: persistedTo, note: "outside-project-read" });
312
+ opts.ctx.ui?.notify?.(`pi-sandbox: allowed read of ${subject} (${picked.scope}${picked.folder ? ", folder" : ""}) — retry the bash command`, "warning");
313
+ decisionHint = `\n✅ pi-sandbox: ${subject} now allowed (${picked.scope}${picked.folder ? ", folder" : ""}). Retry the bash command.\n`;
314
+ } catch (e) {
315
+ auditL1({ subject, scope: picked.scope, cwd: opts.ctx.cwd, error: String(e), note: "outside-project-read" });
316
+ decisionHint = `\n❌ pi-sandbox: could not save the permission (${e}). ${subject} remains blocked.\n`;
317
+ }
308
318
  }
309
319
  }
320
+ } catch {
321
+ /* prompt failure shouldn't crash bash */
310
322
  }
311
- } catch {
312
- /* prompt failure shouldn't crash bash */
313
- }
314
- if (!decisionHint) {
315
- auditL1({ subject: absPath, decision: "no", cwd: opts.ctx.cwd, note: "outside-project-read" });
316
- decisionHint = `\n${blockedLine("Read", "outside the project", "read")}\n`;
317
- }
318
- onData(Buffer.from(decisionHint));
319
- } else if (offending && readDenied) {
320
- const why = hardDenied ? hardWhy : "outside the project";
321
- auditL1({ subject: offending, decision: "read-denied", reason: why, cwd });
322
- opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a read of ${offending} (${why}). Edit sandbox.json if that is wrong.`, "warning");
323
- decisionHint = `\n${blockedLine("Read", why, "read")}\n`;
324
- onData(Buffer.from(decisionHint));
325
- } else if (offending && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways) {
326
- const absPath = offending;
327
- try {
328
- const ui = opts.ctx as AskCtx;
329
- const main = await askMain(ui, "Write blocked by policy", ` path: ${absPath}\n why: not under any allowWrite root\n layer: bash sandbox`, { once: false });
330
- if (main === "remember") {
331
- const parentDir = dirname(absPath);
332
- const folder = isSafeFolderGrant(parentDir, homedir()) ? parentDir : null;
333
- const picked = await askRememberFile(ui, "Remember this write?", ` file: ${absPath}\n folder: ${parentDir}`, absPath, folder);
334
- if (picked) {
335
- const subject = picked.folder ? parentDir : absPath;
336
- try {
337
- const persistedTo = await opts.onAlways(subject, picked.scope);
338
- auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, decision: picked.scope === "cwd" ? "always-cwd" : "always-global", scope: picked.scope, cwd: opts.ctx.cwd, persisted_to: persistedTo });
339
- opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${subject} (${picked.scope}${picked.folder ? ", folder" : ""}) — retry the bash command`, "warning");
340
- decisionHint = `\n✅ pi-sandbox: ${subject} now allowed (${picked.scope}${picked.folder ? ", folder" : ""}). Retry the bash command.\n`;
341
- } catch (e) {
342
- auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, scope: picked.scope, cwd: opts.ctx.cwd, error: String(e) });
343
- opts.ctx.ui?.notify?.(`pi-sandbox: could not save the permission (${e})`, "error");
344
- decisionHint = `\n❌ pi-sandbox: could not save the permission (${e}). ${subject} remains blocked.\n`;
323
+ if (!decisionHint) {
324
+ auditL1({ subject: absPath, decision: "no", cwd: opts.ctx.cwd, note: "outside-project-read" });
325
+ decisionHint = `\n${blockedLine("Read", "outside the project", "read")}\n`;
326
+ }
327
+ onData(Buffer.from(decisionHint));
328
+ } else if (offending && readDenied) {
329
+ 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`;
333
+ onData(Buffer.from(decisionHint));
334
+ } else if (offending && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways) {
335
+ const absPath = offending;
336
+ const parentDir = dirname(absPath);
337
+ const alreadyGranted = writeOnce.includes(parentDir);
338
+ try {
339
+ 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 });
341
+ if (main === "once") {
342
+ auditL1({ subject: absPath, granularity: "folder", original: absPath, decision: "once", scope: "invocation", cwd: opts.ctx.cwd, note: "write-once" });
343
+ opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${absPath} once — re-running the command`, "warning");
344
+ onData(Buffer.from(`\n✅ pi-sandbox: allowed ${absPath} once — re-running the command.\n`));
345
+ resolve(attempt([...writeOnce, parentDir]));
346
+ return;
347
+ }
348
+ if (main === "remember") {
349
+ const folder = isSafeFolderGrant(parentDir, homedir()) ? parentDir : null;
350
+ const picked = await askRememberFile(ui, "Remember this write?", ` file: ${absPath}\n folder: ${parentDir}`, absPath, folder);
351
+ if (picked) {
352
+ const subject = picked.folder ? parentDir : absPath;
353
+ try {
354
+ const persistedTo = await opts.onAlways(subject, picked.scope);
355
+ auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, decision: picked.scope === "cwd" ? "always-cwd" : "always-global", scope: picked.scope, cwd: opts.ctx.cwd, persisted_to: persistedTo });
356
+ opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${subject} (${picked.scope}${picked.folder ? ", folder" : ""}) — retry the bash command`, "warning");
357
+ decisionHint = `\n✅ pi-sandbox: ${subject} now allowed (${picked.scope}${picked.folder ? ", folder" : ""}). Retry the bash command.\n`;
358
+ } catch (e) {
359
+ auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, scope: picked.scope, cwd: opts.ctx.cwd, error: String(e) });
360
+ opts.ctx.ui?.notify?.(`pi-sandbox: could not save the permission (${e})`, "error");
361
+ decisionHint = `\n❌ pi-sandbox: could not save the permission (${e}). ${subject} remains blocked.\n`;
362
+ }
345
363
  }
346
364
  }
365
+ } catch {
366
+ /* prompt failure shouldn't crash bash */
347
367
  }
348
- } catch {
349
- /* prompt failure shouldn't crash bash */
350
- }
351
- if (!decisionHint) {
352
- auditL1({ subject: absPath, decision: "no", cwd: opts.ctx.cwd });
353
- decisionHint = `\n${blockedLine("Write", "user denied", "written")}\n`;
368
+ if (!decisionHint) {
369
+ auditL1({ subject: absPath, decision: "no", cwd: opts.ctx.cwd });
370
+ decisionHint = `\n${blockedLine("Write", "user denied", "written")}\n`;
371
+ }
372
+ onData(Buffer.from(decisionHint));
354
373
  }
355
- onData(Buffer.from(decisionHint));
356
- }
357
374
 
358
- if (signal?.aborted) {
359
- reject(new Error("aborted"));
360
- } else if (timedOut) {
361
- reject(new Error(`timeout:${timeout}`));
362
- } else {
363
- resolve({ exitCode: code });
364
- }
375
+ if (signal?.aborted) {
376
+ reject(new Error("aborted"));
377
+ } else if (timedOut) {
378
+ reject(new Error(`timeout:${timeout}`));
379
+ } else {
380
+ resolve({ exitCode: code });
381
+ }
382
+ });
365
383
  });
366
- });
384
+ };
385
+
386
+ return attempt([]);
367
387
  },
368
388
  };
369
389
  }