auto-permission-system 2.20.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/l1-sandbox/bash-ops.ts +190 -170
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ Never asked about: the project itself, your `allowWrite` roots, pi's own package
|
|
|
123
123
|
|
|
124
124
|
### Ask-tier prompt
|
|
125
125
|
|
|
126
|
-
`no — block` is pre-selected, so Enter alone blocks. Besides `yes — this once` and the persistent `always` options there is `yes — for this session` (this file, its folder, or this domain), kept in memory until the session ends and never saved (ADR-010).
|
|
126
|
+
`no — block` is pre-selected, so Enter alone blocks. Besides `yes — this once` and the persistent `always` options there is `yes — for this session` (this file, its folder, or this domain), kept in memory until the session ends and never saved (ADR-010). For a bash write that the sandbox refused after the command already ran, `yes — this once` re-runs the command once with that folder allowed for the one invocation only (never saved), and `denyWrite` still wins.
|
|
127
127
|
|
|
128
128
|
Every prompt waits **10 seconds** by default. If you do not answer, it resolves to the safe default: **block / deny**.
|
|
129
129
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-permission-system",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Security sandbox, permission system and tool guard for the Pi coding agent (pi): enforce filesystem allow/deny lists and network domain allowlists on read, write, edit, bash, grep, find, ls, fetch_content and web_search, with interactive ask prompts, secret detection and an audit log.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Navin Kumar",
|
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.
|
|
87
|
+
|
|
86
88
|
Every prompt waits **10 seconds** by default; no answer means the safe default: **block / deny**. Esc on any screen also blocks.
|
|
87
89
|
|
|
88
90
|
## Sensitive reads ask
|
|
@@ -157,38 +157,69 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
157
157
|
// "this once" grants apply to this one invocation only: hand them to
|
|
158
158
|
// wrapWithSandbox as a customConfig, so nothing is persisted and the
|
|
159
159
|
// session-wide sandbox is not re-initialised.
|
|
160
|
-
|
|
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,163 @@ 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
|
-
outsideDenied = outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
|
|
250
|
-
outsideMode = outsideProjectMode(fsCfg);
|
|
240
|
+
let offending: string | undefined;
|
|
241
|
+
let readDenied = false;
|
|
242
|
+
let hardDenied = false;
|
|
243
|
+
let hardWhy = "";
|
|
244
|
+
let outsideDenied = false;
|
|
245
|
+
let outsideMode: "allow" | "ask" | "deny" = "allow";
|
|
246
|
+
if (/operation not permitted|EPERM|EACCES/i.test(outputTail)) {
|
|
247
|
+
// Relative paths resolve against the command's cwd: "./.env" is not "/.env".
|
|
248
|
+
offending = extractBlockedPath(outputTail, cwd, homedir());
|
|
249
|
+
// A hard denyRead path (secret material): a write grant would not help,
|
|
250
|
+
// and "always" is not offered. The outside-project fence (ADR-014) is
|
|
251
|
+
// ask-able, so keep the two reasons apart.
|
|
252
|
+
if (offending) {
|
|
253
|
+
const fsCfg = loadConfig(cwd).filesystem;
|
|
254
|
+
if (fsCfg) {
|
|
255
|
+
const hardPat = fsCfg.denyRead.find((pat) => matchesPolicyPattern(offending as string, pat, cwd, homedir()));
|
|
256
|
+
hardDenied = hardPat !== undefined;
|
|
257
|
+
hardWhy = hardPat ? `denyRead matched "${hardPat}"` : "denyRead";
|
|
258
|
+
outsideDenied = outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
|
|
259
|
+
outsideMode = outsideProjectMode(fsCfg);
|
|
260
|
+
}
|
|
261
|
+
readDenied = hardDenied || outsideDenied;
|
|
251
262
|
}
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
const configDirHint = offending && /\.config\/|\.kube\/|\.docker\/|\.netrc|\.aws\/|\.npmrc|\.gitconfig/.test(offending);
|
|
263
|
+
const configDirHint = offending && /\.config\/|\.kube\/|\.docker\/|\.netrc|\.aws\/|\.npmrc|\.gitconfig/.test(offending);
|
|
255
264
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
265
|
+
let hint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
|
|
266
|
+
if (offending) {
|
|
267
|
+
let why = "";
|
|
268
|
+
if (hardDenied) why = " (denyRead: reading it is blocked by policy)";
|
|
269
|
+
else if (outsideDenied) why = " (outside the project: reading it is gated by policy)";
|
|
270
|
+
hint += ` Path: ${offending}${why}\n`;
|
|
271
|
+
}
|
|
272
|
+
hint += ` This is the pi sandbox (Layer 1), NOT macOS Full Disk Access / TCC.\n`;
|
|
273
|
+
if (configDirHint) {
|
|
274
|
+
hint +=
|
|
275
|
+
` Looks like a tool's own config dir. To allow this tool in the\n` +
|
|
276
|
+
` current project, add a project-local policy:\n` +
|
|
277
|
+
` mkdir -p ${cwd}/.pi && cat > ${cwd}/.pi/sandbox.json <<'JSON'\n` +
|
|
278
|
+
` { "filesystem": { "allowWrite": [".", "${offending?.replace(/^~/, "$HOME") ?? "~/.config/<tool>"}"] } }\n` +
|
|
279
|
+
` JSON\n` +
|
|
280
|
+
` Or run pi with --yolo for one-off elevated access (disables ALL layers).\n`;
|
|
281
|
+
} else {
|
|
282
|
+
hint +=
|
|
283
|
+
` Use $TMPDIR (= ${piTmp}/) for scratch files,\n` +
|
|
284
|
+
` or write inside the project directory (${cwd}).\n`;
|
|
285
|
+
}
|
|
286
|
+
hint += ` Policy: ~/.pi/agent/extensions/sandbox.json (+ <cwd>/.pi/sandbox.json overrides).\n`;
|
|
287
|
+
if (opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways && !readDenied) {
|
|
288
|
+
hint += ` → Waiting for your decision in the prompt above before this bash call returns to the model.\n`;
|
|
289
|
+
}
|
|
290
|
+
onData(Buffer.from(hint));
|
|
280
291
|
}
|
|
281
|
-
onData(Buffer.from(hint));
|
|
282
|
-
}
|
|
283
292
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
293
|
+
// Ask-tier prompt: BEFORE resolve so the agent loop pauses while the
|
|
294
|
+
// user decides. Otherwise the model gets the EPERM hint immediately,
|
|
295
|
+
// tries an alternative, and the prompt sits orphaned in the UI.
|
|
296
|
+
let decisionHint = "";
|
|
297
|
+
if (offending && outsideDenied && !hardDenied && outsideMode === "ask" && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlwaysRead) {
|
|
298
|
+
// ask-tier read grant for a path outside the project (ADR-014).
|
|
299
|
+
const absPath = offending;
|
|
300
|
+
try {
|
|
301
|
+
const ui = opts.ctx as AskCtx;
|
|
302
|
+
const main = await askMain(ui, "Read blocked by policy", ` path: ${absPath}\n why: outside the project\n layer: bash sandbox`, { once: false });
|
|
303
|
+
if (main === "remember") {
|
|
304
|
+
const parent = dirname(absPath);
|
|
305
|
+
const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
|
|
306
|
+
const picked = await askRememberFile(ui, "Remember this read?", ` file: ${absPath}\n folder: ${parent}`, absPath, folder);
|
|
307
|
+
if (picked) {
|
|
308
|
+
const subject = picked.folder ? parent : absPath;
|
|
309
|
+
try {
|
|
310
|
+
const persistedTo = await opts.onAlwaysRead(subject, picked.scope);
|
|
311
|
+
auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, decision: picked.scope === "cwd" ? "always-cwd" : "always-global", scope: picked.scope, cwd: opts.ctx.cwd, persisted_to: persistedTo, note: "outside-project-read" });
|
|
312
|
+
opts.ctx.ui?.notify?.(`pi-sandbox: allowed read of ${subject} (${picked.scope}${picked.folder ? ", folder" : ""}) — retry the bash command`, "warning");
|
|
313
|
+
decisionHint = `\n✅ pi-sandbox: ${subject} now allowed (${picked.scope}${picked.folder ? ", folder" : ""}). Retry the bash command.\n`;
|
|
314
|
+
} catch (e) {
|
|
315
|
+
auditL1({ subject, scope: picked.scope, cwd: opts.ctx.cwd, error: String(e), note: "outside-project-read" });
|
|
316
|
+
decisionHint = `\n❌ pi-sandbox: could not save the permission (${e}). ${subject} remains blocked.\n`;
|
|
317
|
+
}
|
|
308
318
|
}
|
|
309
319
|
}
|
|
320
|
+
} catch {
|
|
321
|
+
/* prompt failure shouldn't crash bash */
|
|
310
322
|
}
|
|
311
|
-
|
|
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
|
-
|
|
323
|
+
if (!decisionHint) {
|
|
324
|
+
auditL1({ subject: absPath, decision: "no", cwd: opts.ctx.cwd, note: "outside-project-read" });
|
|
325
|
+
decisionHint = `\n${blockedLine("Read", "outside the project", "read")}\n`;
|
|
326
|
+
}
|
|
327
|
+
onData(Buffer.from(decisionHint));
|
|
328
|
+
} else if (offending && readDenied) {
|
|
329
|
+
const why = hardDenied ? hardWhy : "outside the project";
|
|
330
|
+
auditL1({ subject: offending, decision: "read-denied", reason: why, cwd });
|
|
331
|
+
opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a read of ${offending} (${why}). Edit sandbox.json if that is wrong.`, "warning");
|
|
332
|
+
decisionHint = `\n${blockedLine("Read", why, "read")}\n`;
|
|
333
|
+
onData(Buffer.from(decisionHint));
|
|
334
|
+
} else if (offending && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways) {
|
|
335
|
+
const absPath = offending;
|
|
336
|
+
const parentDir = dirname(absPath);
|
|
337
|
+
const alreadyGranted = writeOnce.includes(parentDir);
|
|
338
|
+
try {
|
|
339
|
+
const ui = opts.ctx as AskCtx;
|
|
340
|
+
const main = await askMain(ui, "Write blocked by policy", ` path: ${absPath}\n why: not under any allowWrite root\n layer: bash sandbox`, { once: !alreadyGranted });
|
|
341
|
+
if (main === "once") {
|
|
342
|
+
auditL1({ subject: absPath, granularity: "folder", original: absPath, decision: "once", scope: "invocation", cwd: opts.ctx.cwd, note: "write-once" });
|
|
343
|
+
opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${absPath} once — re-running the command`, "warning");
|
|
344
|
+
onData(Buffer.from(`\n✅ pi-sandbox: allowed ${absPath} once — re-running the command.\n`));
|
|
345
|
+
resolve(attempt([...writeOnce, parentDir]));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (main === "remember") {
|
|
349
|
+
const folder = isSafeFolderGrant(parentDir, homedir()) ? parentDir : null;
|
|
350
|
+
const picked = await askRememberFile(ui, "Remember this write?", ` file: ${absPath}\n folder: ${parentDir}`, absPath, folder);
|
|
351
|
+
if (picked) {
|
|
352
|
+
const subject = picked.folder ? parentDir : absPath;
|
|
353
|
+
try {
|
|
354
|
+
const persistedTo = await opts.onAlways(subject, picked.scope);
|
|
355
|
+
auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, decision: picked.scope === "cwd" ? "always-cwd" : "always-global", scope: picked.scope, cwd: opts.ctx.cwd, persisted_to: persistedTo });
|
|
356
|
+
opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${subject} (${picked.scope}${picked.folder ? ", folder" : ""}) — retry the bash command`, "warning");
|
|
357
|
+
decisionHint = `\n✅ pi-sandbox: ${subject} now allowed (${picked.scope}${picked.folder ? ", folder" : ""}). Retry the bash command.\n`;
|
|
358
|
+
} catch (e) {
|
|
359
|
+
auditL1({ subject, granularity: picked.folder ? "folder" : "file", original: absPath, scope: picked.scope, cwd: opts.ctx.cwd, error: String(e) });
|
|
360
|
+
opts.ctx.ui?.notify?.(`pi-sandbox: could not save the permission (${e})`, "error");
|
|
361
|
+
decisionHint = `\n❌ pi-sandbox: could not save the permission (${e}). ${subject} remains blocked.\n`;
|
|
362
|
+
}
|
|
345
363
|
}
|
|
346
364
|
}
|
|
365
|
+
} catch {
|
|
366
|
+
/* prompt failure shouldn't crash bash */
|
|
347
367
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
decisionHint = `\n${blockedLine("Write", "user denied", "written")}\n`;
|
|
368
|
+
if (!decisionHint) {
|
|
369
|
+
auditL1({ subject: absPath, decision: "no", cwd: opts.ctx.cwd });
|
|
370
|
+
decisionHint = `\n${blockedLine("Write", "user denied", "written")}\n`;
|
|
371
|
+
}
|
|
372
|
+
onData(Buffer.from(decisionHint));
|
|
354
373
|
}
|
|
355
|
-
onData(Buffer.from(decisionHint));
|
|
356
|
-
}
|
|
357
374
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
375
|
+
if (signal?.aborted) {
|
|
376
|
+
reject(new Error("aborted"));
|
|
377
|
+
} else if (timedOut) {
|
|
378
|
+
reject(new Error(`timeout:${timeout}`));
|
|
379
|
+
} else {
|
|
380
|
+
resolve({ exitCode: code });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
365
383
|
});
|
|
366
|
-
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
return attempt([]);
|
|
367
387
|
},
|
|
368
388
|
};
|
|
369
389
|
}
|