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.
|
|
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
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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 =
|
|
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
|
|
23
|
-
|
|
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 (
|
|
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 = " (
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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) {
|