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 +1 -1
- package/package.json +1 -1
- package/sandbox.default.json +44 -56
- package/sandbox.example.json +61 -25
- package/skills/auto-permission-system/SKILL.md +2 -0
- package/src/core/policy/patterns.ts +47 -11
- package/src/l1-sandbox/bash-ops.ts +201 -171
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": "
|
|
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",
|
package/sandbox.default.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"enabled": true,
|
|
3
|
-
"mode": "
|
|
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": "
|
|
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
|
-
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
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": [
|
|
106
|
+
"ask": [
|
|
107
|
+
"printenv",
|
|
108
|
+
"env",
|
|
109
|
+
"/proc/*/environ"
|
|
110
|
+
]
|
|
123
111
|
},
|
|
124
112
|
"overrides": {
|
|
125
113
|
"allowRead": [],
|
package/sandbox.example.json
CHANGED
|
@@ -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
|
-
"
|
|
33
|
-
"
|
|
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
|
-
"
|
|
37
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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";
|
|
@@ -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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
+
return {
|
|
164
165
|
filesystem: sandboxFilesystem(
|
|
165
|
-
{ ...fresh, allowRead: [...(fresh.allowRead ?? []), ...
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
timeoutHandle
|
|
191
|
-
|
|
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
|
-
}
|
|
200
|
-
}
|
|
230
|
+
};
|
|
201
231
|
|
|
202
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
238
|
+
SandboxManager.cleanupAfterCommand();
|
|
226
239
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
}
|