auto-permission-system 2.20.0 → 3.0.1

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. 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
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.1",
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. 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
+
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
@@ -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,7 +9,7 @@ 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
15
  import { loadConfig } from "./config";
@@ -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,173 @@ 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 writeDenied = false;
243
+ let hardDenied = false;
244
+ let hardWhy = "";
245
+ let outsideDenied = false;
246
+ let outsideMode: "allow" | "ask" | "deny" = "allow";
247
+ if (isBlockedAccessError(outputTail)) {
248
+ // Relative paths resolve against the command's cwd: "./.env" is not "/.env".
249
+ offending = extractBlockedPath(outputTail, cwd, homedir());
250
+ writeDenied = isWriteBlockError(outputTail);
251
+ // A hard denyRead path (secret material): a write grant would not help,
252
+ // and "always" is not offered. The outside-project fence (ADR-014) is
253
+ // ask-able, so keep the two reasons apart.
254
+ if (offending) {
255
+ const fsCfg = loadConfig(cwd).filesystem;
256
+ if (fsCfg) {
257
+ const hardPat = fsCfg.denyRead.find((pat) => matchesPolicyPattern(offending as string, pat, cwd, homedir()));
258
+ hardDenied = hardPat !== undefined;
259
+ hardWhy = hardPat ? `denyRead matched "${hardPat}"` : "denyRead";
260
+ outsideDenied = !writeDenied && outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
261
+ outsideMode = outsideProjectMode(fsCfg);
262
+ }
263
+ readDenied = hardDenied || outsideDenied;
251
264
  }
252
- readDenied = hardDenied || outsideDenied;
253
- }
254
- const configDirHint = offending && /\.config\/|\.kube\/|\.docker\/|\.netrc|\.aws\/|\.npmrc|\.gitconfig/.test(offending);
265
+ const configDirHint = offending && /\.config\/|\.kube\/|\.docker\/|\.netrc|\.aws\/|\.npmrc|\.gitconfig/.test(offending);
255
266
 
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`;
267
+ let hint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
268
+ if (offending) {
269
+ let why = "";
270
+ if (writeDenied && !hardDenied) why = " (not under an allowWrite root: writes outside it are read-only)";
271
+ else if (hardDenied) why = " (denyRead: reading it is blocked by policy)";
272
+ else if (outsideDenied) why = " (outside the project: reading it is gated by policy)";
273
+ hint += ` Path: ${offending}${why}\n`;
274
+ }
275
+ hint += ` This is the pi sandbox (Layer 1), NOT macOS Full Disk Access / TCC.\n`;
276
+ if (configDirHint) {
277
+ hint +=
278
+ ` Looks like a tool's own config dir. To allow this tool in the\n` +
279
+ ` current project, add a project-local policy:\n` +
280
+ ` mkdir -p ${cwd}/.pi && cat > ${cwd}/.pi/sandbox.json <<'JSON'\n` +
281
+ ` { "filesystem": { "allowWrite": [".", "${offending?.replace(/^~/, "$HOME") ?? "~/.config/<tool>"}"] } }\n` +
282
+ ` JSON\n` +
283
+ ` Or run pi with --yolo for one-off elevated access (disables ALL layers).\n`;
284
+ } else {
285
+ hint +=
286
+ ` Use $TMPDIR (= ${piTmp}/) for scratch files,\n` +
287
+ ` or write inside the project directory (${cwd}).\n`;
288
+ }
289
+ hint += ` Policy: ~/.pi/agent/extensions/sandbox.json (+ <cwd>/.pi/sandbox.json overrides).\n`;
290
+ if (opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways && !readDenied) {
291
+ hint += ` → Waiting for your decision in the prompt above before this bash call returns to the model.\n`;
292
+ }
293
+ onData(Buffer.from(hint));
280
294
  }
281
- onData(Buffer.from(hint));
282
- }
283
295
 
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`;
296
+ // Ask-tier prompt: BEFORE resolve so the agent loop pauses while the
297
+ // user decides. Otherwise the model gets the EPERM hint immediately,
298
+ // tries an alternative, and the prompt sits orphaned in the UI.
299
+ let decisionHint = "";
300
+ if (offending && outsideDenied && !hardDenied && outsideMode === "ask" && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlwaysRead) {
301
+ // ask-tier read grant for a path outside the project (ADR-014).
302
+ const absPath = offending;
303
+ try {
304
+ 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 });
306
+ if (main === "remember") {
307
+ const parent = dirname(absPath);
308
+ const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
309
+ const picked = await askRememberFile(ui, "Remember this read?", ` file: ${absPath}\n folder: ${parent}`, absPath, folder);
310
+ if (picked) {
311
+ const subject = picked.folder ? parent : absPath;
312
+ try {
313
+ const persistedTo = await opts.onAlwaysRead(subject, picked.scope);
314
+ 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" });
315
+ opts.ctx.ui?.notify?.(`pi-sandbox: allowed read of ${subject} (${picked.scope}${picked.folder ? ", folder" : ""}) — retry the bash command`, "warning");
316
+ decisionHint = `\n✅ pi-sandbox: ${subject} now allowed (${picked.scope}${picked.folder ? ", folder" : ""}). Retry the bash command.\n`;
317
+ } catch (e) {
318
+ auditL1({ subject, scope: picked.scope, cwd: opts.ctx.cwd, error: String(e), note: "outside-project-read" });
319
+ decisionHint = `\n❌ pi-sandbox: could not save the permission (${e}). ${subject} remains blocked.\n`;
320
+ }
308
321
  }
309
322
  }
323
+ } catch {
324
+ /* prompt failure shouldn't crash bash */
310
325
  }
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`;
326
+ if (!decisionHint) {
327
+ auditL1({ subject: absPath, decision: "no", cwd: opts.ctx.cwd, note: "outside-project-read" });
328
+ decisionHint = `\n${blockedLine("Read", "outside the project", "read")}\n`;
329
+ }
330
+ onData(Buffer.from(decisionHint));
331
+ } else if (offending && readDenied) {
332
+ const why = hardDenied ? hardWhy : "outside the project";
333
+ const action: "Read" | "Write" = writeDenied ? "Write" : "Read";
334
+ const outcome = action === "Write" ? "written" : "read";
335
+ auditL1({ subject: offending, decision: readDenied && !writeDenied ? "read-denied" : "write-denied", reason: why, cwd });
336
+ opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a ${action.toLowerCase()} of ${offending} (${why}). Edit sandbox.json if that is wrong.`, "warning");
337
+ decisionHint = `\n${blockedLine(action, why, outcome)}\n`;
338
+ onData(Buffer.from(decisionHint));
339
+ } else if (offending && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways) {
340
+ const absPath = offending;
341
+ const parentDir = dirname(absPath);
342
+ const alreadyGranted = writeOnce.includes(parentDir);
343
+ try {
344
+ 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 });
346
+ if (main === "once") {
347
+ auditL1({ subject: absPath, granularity: "folder", original: absPath, decision: "once", scope: "invocation", cwd: opts.ctx.cwd, note: "write-once" });
348
+ opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${absPath} once — re-running the command`, "warning");
349
+ onData(Buffer.from(`\n✅ pi-sandbox: allowed ${absPath} once — re-running the command.\n`));
350
+ resolve(attempt([...writeOnce, parentDir]));
351
+ return;
352
+ }
353
+ if (main === "remember") {
354
+ const folder = isSafeFolderGrant(parentDir, homedir()) ? parentDir : null;
355
+ const picked = await askRememberFile(ui, "Remember this write?", ` file: ${absPath}\n folder: ${parentDir}`, absPath, folder);
356
+ if (picked) {
357
+ const subject = picked.folder ? parentDir : absPath;
358
+ try {
359
+ const persistedTo = await opts.onAlways(subject, picked.scope);
360
+ 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 });
361
+ opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${subject} (${picked.scope}${picked.folder ? ", folder" : ""}) — retry the bash command`, "warning");
362
+ decisionHint = `\n✅ pi-sandbox: ${subject} now allowed (${picked.scope}${picked.folder ? ", folder" : ""}). Retry the bash command.\n`;
363
+ } catch (e) {
364
+ auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, scope: picked.scope, cwd: opts.ctx.cwd, error: String(e) });
365
+ opts.ctx.ui?.notify?.(`pi-sandbox: could not save the permission (${e})`, "error");
366
+ decisionHint = `\n❌ pi-sandbox: could not save the permission (${e}). ${subject} remains blocked.\n`;
367
+ }
345
368
  }
346
369
  }
370
+ } catch {
371
+ /* prompt failure shouldn't crash bash */
347
372
  }
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`;
373
+ if (!decisionHint) {
374
+ auditL1({ subject: absPath, decision: "no", cwd: opts.ctx.cwd });
375
+ decisionHint = `\n${blockedLine("Write", "user denied", "written")}\n`;
376
+ }
377
+ onData(Buffer.from(decisionHint));
378
+ } else if (offending && writeDenied) {
379
+ // No UI to prompt with: report the fence and the path to the model.
380
+ auditL1({ subject: offending, decision: "write-denied", reason: "not under any allowWrite root", cwd });
381
+ opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a write of ${offending} (not under any allowWrite root).`, "warning");
382
+ onData(Buffer.from(`\n${blockedLine("Write", "not under any allowWrite root", "written")}\n`));
354
383
  }
355
- onData(Buffer.from(decisionHint));
356
- }
357
384
 
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
- }
385
+ if (signal?.aborted) {
386
+ reject(new Error("aborted"));
387
+ } else if (timedOut) {
388
+ reject(new Error(`timeout:${timeout}`));
389
+ } else {
390
+ resolve({ exitCode: code });
391
+ }
392
+ });
365
393
  });
366
- });
394
+ };
395
+
396
+ return attempt([]);
367
397
  },
368
398
  };
369
399
  }