auto-permission-system 3.0.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). 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.
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": "3.0.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",
@@ -83,7 +83,7 @@ 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.
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
87
 
88
88
  Every prompt waits **10 seconds** by default; no answer means the safe default: **block / deny**. Esc on any screen also blocks.
89
89
 
@@ -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";
@@ -239,13 +239,15 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
239
239
 
240
240
  let offending: string | undefined;
241
241
  let readDenied = false;
242
+ let writeDenied = false;
242
243
  let hardDenied = false;
243
244
  let hardWhy = "";
244
245
  let outsideDenied = false;
245
246
  let outsideMode: "allow" | "ask" | "deny" = "allow";
246
- if (/operation not permitted|EPERM|EACCES/i.test(outputTail)) {
247
+ if (isBlockedAccessError(outputTail)) {
247
248
  // Relative paths resolve against the command's cwd: "./.env" is not "/.env".
248
249
  offending = extractBlockedPath(outputTail, cwd, homedir());
250
+ writeDenied = isWriteBlockError(outputTail);
249
251
  // A hard denyRead path (secret material): a write grant would not help,
250
252
  // and "always" is not offered. The outside-project fence (ADR-014) is
251
253
  // ask-able, so keep the two reasons apart.
@@ -255,7 +257,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
255
257
  const hardPat = fsCfg.denyRead.find((pat) => matchesPolicyPattern(offending as string, pat, cwd, homedir()));
256
258
  hardDenied = hardPat !== undefined;
257
259
  hardWhy = hardPat ? `denyRead matched "${hardPat}"` : "denyRead";
258
- outsideDenied = outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
260
+ outsideDenied = !writeDenied && outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
259
261
  outsideMode = outsideProjectMode(fsCfg);
260
262
  }
261
263
  readDenied = hardDenied || outsideDenied;
@@ -265,7 +267,8 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
265
267
  let hint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
266
268
  if (offending) {
267
269
  let why = "";
268
- if (hardDenied) why = " (denyRead: reading it is blocked by policy)";
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)";
269
272
  else if (outsideDenied) why = " (outside the project: reading it is gated by policy)";
270
273
  hint += ` Path: ${offending}${why}\n`;
271
274
  }
@@ -327,9 +330,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
327
330
  onData(Buffer.from(decisionHint));
328
331
  } else if (offending && readDenied) {
329
332
  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
+ 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`;
333
338
  onData(Buffer.from(decisionHint));
334
339
  } else if (offending && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways) {
335
340
  const absPath = offending;
@@ -370,6 +375,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
370
375
  decisionHint = `\n${blockedLine("Write", "user denied", "written")}\n`;
371
376
  }
372
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`));
373
383
  }
374
384
 
375
385
  if (signal?.aborted) {