auto-permission-system 3.0.0 → 3.6.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 +7 -6
- package/extensions/guard.ts +52 -8
- package/extensions/sandbox.ts +36 -5
- package/package.json +3 -2
- package/skills/auto-permission-system/SKILL.md +10 -5
- package/src/core/index.ts +1 -0
- package/src/core/policy/domains.ts +26 -0
- package/src/core/policy/patterns.ts +47 -11
- package/src/l1-sandbox/bash-ops.ts +45 -18
- package/src/l1-sandbox/manager.ts +64 -15
- package/src/l1-sandbox/network-ask.ts +198 -0
- package/src/l2-guard/exposure.ts +13 -3
- package/src/l2-guard/program-grants.ts +35 -0
- package/src/l2-guard/program-id.ts +37 -0
- package/src/l2-guard/prompts.ts +14 -8
- package/src/l2-guard/url.ts +12 -17
- package/src/shared/network-grants.ts +31 -0
- package/src/ui/ask-flow.ts +345 -45
- package/src/ui/ask-selector.ts +227 -0
- package/src/ui/ask.ts +249 -0
- package/src/l2-guard/ask.ts +0 -95
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ It puts allow/deny rules, interactive ask prompts, secret detection and an audit
|
|
|
11
11
|
|
|
12
12
|
Enforcement happens in three layers:
|
|
13
13
|
|
|
14
|
-
- **Layer 1** — OS-level bash sandbox (`sandbox-exec` on macOS, `bubblewrap` on Linux) that blocks filesystem writes outside an allow-list, reads of sensitive paths, reads outside the project when `outsideProject.read` gates them, and network egress to unlisted domains.
|
|
14
|
+
- **Layer 1** — OS-level bash sandbox (`sandbox-exec` on macOS, `bubblewrap` on Linux) that blocks filesystem writes outside an allow-list, reads of sensitive paths, reads outside the project when `outsideProject.read` gates them, and network egress to unlisted domains — an unknown host asks first (a host in `deniedDomains` never does), with the same once/session/remember options as Layer 2.
|
|
15
15
|
- **Layer 2** — In-process tool guard applying the same policy to the tools the OS sandbox can't reach: `read`, `grep`, `find`, `ls`, `write`, `edit`, `fetch_content`, `web_search`, `get_search_content`. `grep` output lines from denied files beneath an allowed search root are removed before the model sees them.
|
|
16
16
|
- **Layer 3** — Subagent posture: optionally drop or restrict network access when running headless (`-p`, JSON mode, subagents).
|
|
17
17
|
|
|
@@ -72,11 +72,12 @@ A higher layer only states what it changes; absent keys keep the layer below. A
|
|
|
72
72
|
"enabled": true, // set false to disable Layer 2 without --yolo
|
|
73
73
|
"mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
|
|
74
74
|
"network": {
|
|
75
|
-
"allowedDomains": [ // domains
|
|
75
|
+
"allowedDomains": [ // domains the URL tools and bash may reach
|
|
76
76
|
"github.com", "*.github.com",
|
|
77
77
|
"registry.npmjs.org"
|
|
78
78
|
],
|
|
79
|
-
"deniedDomains": []
|
|
79
|
+
"deniedDomains": [], // hard deny, checked first, never ask-able
|
|
80
|
+
"strictAllowlist": false // true = deny unknown hosts instead of asking
|
|
80
81
|
},
|
|
81
82
|
"filesystem": {
|
|
82
83
|
"denyRead": ["~/.ssh", "~/.aws", "~/.pi/agent"], // Layer 1 + Layer 2: no read at all
|
|
@@ -123,9 +124,9 @@ Never asked about: the project itself, your `allowWrite` roots, pi's own package
|
|
|
123
124
|
|
|
124
125
|
### Ask-tier prompt
|
|
125
126
|
|
|
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
|
+
`no — block` is pre-selected, so Enter alone blocks. **Network (domain) asks are the exception** (ADR-024): the countdown default is `Allow once` — the footer reads `Default: Yes, just this once (15s)` — so an unanswered countdown counts as allow-once for that bash command, while Enter and Esc still block/deny. Once you press ↑/↓ the `Default:` line disappears and the 15s activity timer restarts (ADR-031). 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). A bash command that reaches a domain outside `allowedDomains` gets the same prompt (ADR-023); because the sandbox proxy asks per connection, `yes — this once` covers that command's connections to the host, and a remembered host is written to `overrides.allowDomains` and applies immediately. Hosts in `deniedDomains`, or with `strictAllowlist: true`, are blocked without a prompt. Prompts are shown one at a time: the extension serializes them, because pi's selector is a singleton and two overlapping prompts can orphan each other and hang the waiting connection. 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
128
|
|
|
128
|
-
Every prompt waits **10 seconds** by default.
|
|
129
|
+
Every prompt waits **10 seconds** by default. For file reads and writes, no answer resolves to the safe default: **block / deny**. For a network ask, no answer resolves to **allow once** (ADR-024); Esc still denies.
|
|
129
130
|
|
|
130
131
|
### File-name patterns
|
|
131
132
|
|
|
@@ -200,7 +201,7 @@ Every block/allow/always decision is appended to `~/.pi/agent/audit.log` as a JS
|
|
|
200
201
|
## FAQ
|
|
201
202
|
|
|
202
203
|
### How do I allow the agent to reach a new domain?
|
|
203
|
-
Add it to `network.allowedDomains` in a policy file (global `~/.pi/agent/extensions/sandbox.json` or project `<cwd>/.pi/sandbox.json`), or just run the tool once and pick an **always** option in the ask prompt — that writes the domain to the project file for you.
|
|
204
|
+
Add it to `network.allowedDomains` in a policy file (global `~/.pi/agent/extensions/sandbox.json` or project `<cwd>/.pi/sandbox.json`), or just run the tool (or a bash `curl`) once and pick an **always** option in the ask prompt — that writes the domain to the project file for you.
|
|
204
205
|
|
|
205
206
|
### How do I allow writes to another folder?
|
|
206
207
|
Add the folder to `filesystem.allowWrite`. Relative paths (`.`, `/tmp`) resolve from the project root.
|
package/extensions/guard.ts
CHANGED
|
@@ -16,10 +16,13 @@ import { collectExposure } from "../src/l2-guard/exposure";
|
|
|
16
16
|
import { askExposure, askMain, withheldNotice } from "../src/ui/ask-flow";
|
|
17
17
|
import { loadPolicy, projectPolicyPath, projectTrusted, setPiDeclinedTrust, untrustedProjectChanges, bullets, TRUST_STORE } from "../src/l2-guard/policy";
|
|
18
18
|
import { canonicalize, expandHome, isAbsoluteDeny, isAskRead, isDeniedRead, isDeniedWrite, outsideProjectReason } from "../src/l2-guard/matching";
|
|
19
|
-
import { hostnameOf, isAllowedUrl } from "../src/l2-guard/url";
|
|
19
|
+
import { deniedUrlReason, hostnameOf, isAllowedUrl } from "../src/l2-guard/url";
|
|
20
20
|
import { audit, AUDIT_PATH } from "../src/shared/audit";
|
|
21
21
|
import { askOrBlock, clearSessionGrants, sessionGrantSummary, type UICtx } from "../src/l2-guard/prompts";
|
|
22
|
-
import {
|
|
22
|
+
import { grantProgramOutputSession, isProgramOutputGranted, clearProgramOutputSessionGrants, programOutputSessionGrantSummary } from "../src/l2-guard/program-grants";
|
|
23
|
+
import { programIdForToolCall } from "../src/l2-guard/program-id";
|
|
24
|
+
import { denyMessage } from "../src/ui/ask";
|
|
25
|
+
import { emitDomainGrant } from "../src/shared/network-grants";
|
|
23
26
|
import { subagentNetworkBlock } from "../src/l2-guard/subagent";
|
|
24
27
|
import { onYolo } from "../src/shared/yolo";
|
|
25
28
|
|
|
@@ -35,6 +38,11 @@ function detectionBlock(action: "read" | "tool", why: string): string {
|
|
|
35
38
|
return `Tool call blocked by policy: ${why}. Nothing was run — ask the user.`;
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/** Maps each in-flight tool call to the program id we'll show in the
|
|
42
|
+
* Advanced Secure output gate. Populated at `tool_call`, consumed at
|
|
43
|
+
* `tool_result`, cleared at `session_start`. */
|
|
44
|
+
const programIdByCallId = new Map<string, string>();
|
|
45
|
+
|
|
38
46
|
async function askDetection(
|
|
39
47
|
ctx: UICtx,
|
|
40
48
|
kind: "read" | "tool",
|
|
@@ -135,12 +143,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
135
143
|
applyActive();
|
|
136
144
|
clearSessionGrants();
|
|
137
145
|
detectionGrants.clear();
|
|
146
|
+
clearProgramOutputSessionGrants();
|
|
147
|
+
programIdByCallId.clear();
|
|
138
148
|
});
|
|
139
149
|
|
|
140
150
|
pi.on("tool_call", async (event, ctx) => {
|
|
141
151
|
if (!active) return;
|
|
142
152
|
const policy = loadPolicy(ctx.cwd);
|
|
143
153
|
|
|
154
|
+
// Capture the program id (binary for bash, MCP server for mcp__ tools) so the
|
|
155
|
+
// Advanced Secure output gate can label the prompt and honor a session grant
|
|
156
|
+
// keyed by that program. Falls back to the tool name when nothing else fits.
|
|
157
|
+
const programId = programIdForToolCall(event.toolName, event.input);
|
|
158
|
+
if (event.toolCallId && programId) programIdByCallId.set(event.toolCallId, programId);
|
|
159
|
+
|
|
144
160
|
// --- Path-based gates (with ask-tier prompt) ---
|
|
145
161
|
// One gate for every read: the read policy first, then the project boundary.
|
|
146
162
|
const gateRead = async (rawPath: string, tool: string) => {
|
|
@@ -218,10 +234,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
234
|
const saReason = subagentNetworkBlock(ctx as { hasUI?: boolean; sessionManager?: unknown }, policy);
|
|
219
235
|
if (saReason) return { block: true, reason: saReason };
|
|
220
236
|
for (const u of collectUrls()) {
|
|
237
|
+
// deniedDomains is the hard-deny tier: the bash proxy refuses it before
|
|
238
|
+
// any ask, so Layer 2 must not offer to override it either.
|
|
239
|
+
const denied = deniedUrlReason(u, policy);
|
|
240
|
+
if (denied) {
|
|
241
|
+
audit({ layer: 2, tool: event.toolName, subject: u, reason: denied, decision: "no", note: "denied-domain", cwd: ctx.cwd });
|
|
242
|
+
return { block: true, reason: denyMessage("allowDomains", denied) };
|
|
243
|
+
}
|
|
221
244
|
const reason = isAllowedUrl(u, policy);
|
|
222
245
|
if (!reason) continue;
|
|
223
246
|
const host = hostnameOf(u) ?? u;
|
|
224
|
-
const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, { layer: 2, tool: event.toolName, subject: u, reason, overrideKind: "allowDomains", overrideValue: host }, null)
|
|
247
|
+
const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, { layer: 2, tool: event.toolName, subject: u, reason, overrideKind: "allowDomains", overrideValue: host }, null, (kind, value) => {
|
|
248
|
+
// Layer 1's proxy does not re-read the file: apply the remembered host live (ADR-023).
|
|
249
|
+
if (kind === "allowDomains") emitDomainGrant(pi.events, value);
|
|
250
|
+
});
|
|
225
251
|
if (result) return result;
|
|
226
252
|
}
|
|
227
253
|
}
|
|
@@ -281,17 +307,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
307
|
if (!active || event.isError) return;
|
|
282
308
|
const policy = loadPolicy(ctx.cwd);
|
|
283
309
|
if (policy.mode !== "advanced-secure") return;
|
|
310
|
+
const programId = event.toolCallId ? programIdByCallId.get(event.toolCallId) : undefined;
|
|
311
|
+
if (programId && isProgramOutputGranted(programId)) {
|
|
312
|
+
audit({ layer: 2, tool: event.toolName, decision: "session-grant", grant: programId, note: "advanced-secure-output", cwd: ctx.cwd });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
284
315
|
const rawInput = event.input ?? {};
|
|
285
316
|
const rawPath = rawInput.path ?? rawInput.file_path;
|
|
286
|
-
const subject =
|
|
317
|
+
const subject = (() => {
|
|
318
|
+
if (typeof rawPath === "string" && rawPath) return rawPath;
|
|
319
|
+
if (event.toolName === "bash") {
|
|
320
|
+
const cmd = (event.input as { command?: string } | undefined)?.command;
|
|
321
|
+
return cmd ? `bash: ${cmd}` : "bash";
|
|
322
|
+
}
|
|
323
|
+
return event.toolName;
|
|
324
|
+
})();
|
|
287
325
|
const { types, hits } = collectExposure(event.content, subject);
|
|
288
326
|
if (!types.length) return;
|
|
289
327
|
const findings = types;
|
|
290
328
|
const ui = /* SAFETY: pi's ctx carries cwd/UI at runtime; the local type only names the members used. */ ctx as unknown as UICtx;
|
|
291
|
-
const
|
|
292
|
-
if (allow) {
|
|
293
|
-
|
|
294
|
-
|
|
329
|
+
const choice = ui.hasUI !== false ? await askExposure(ui, hits, programId) : "block";
|
|
330
|
+
if (choice === "allow" || choice === "program-session") {
|
|
331
|
+
if (choice === "program-session" && programId) {
|
|
332
|
+
grantProgramOutputSession(programId);
|
|
333
|
+
audit({ layer: 2, tool: event.toolName, decision: "session", grant: programId, note: "advanced-secure-output", findings, cwd: ctx.cwd });
|
|
334
|
+
ctx.ui.notify(`🛡 Advanced Secure: you allowed all ${programId} output for the rest of this session.`, "info");
|
|
335
|
+
} else {
|
|
336
|
+
audit({ layer: 2, tool: event.toolName, decision: "yes", note: "advanced-secure-output", findings, cwd: ctx.cwd });
|
|
337
|
+
ctx.ui.notify(`🛡 Advanced Secure: you allowed ${event.toolName} output to be sent.`, "warning");
|
|
338
|
+
}
|
|
295
339
|
return;
|
|
296
340
|
}
|
|
297
341
|
audit({ layer: 2, tool: event.toolName, decision: "no", note: "advanced-secure-output", findings, cwd: ctx.cwd });
|
package/extensions/sandbox.ts
CHANGED
|
@@ -31,8 +31,11 @@
|
|
|
31
31
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
32
32
|
import { createBashTool } from "@earendil-works/pi-coding-agent";
|
|
33
33
|
import { createSandboxedBashOps } from "../src/l1-sandbox/bash-ops";
|
|
34
|
-
import { initSandbox, persistLayer1Override, reloadSandbox, resetSandbox } from "../src/l1-sandbox/manager";
|
|
34
|
+
import { applyNetworkGrant, initSandbox, persistLayer1Override, reloadSandbox, resetSandbox, writeNetworkOverride } from "../src/l1-sandbox/manager";
|
|
35
|
+
import { clearNetworkSessionGrants, type NetworkAskCtx, type NetworkAskDeps } from "../src/l1-sandbox/network-ask";
|
|
35
36
|
import { loadConfig, setPiDeclinedTrust } from "../src/l1-sandbox/config";
|
|
37
|
+
import { audit } from "../src/shared/audit";
|
|
38
|
+
import { onDomainGrant } from "../src/shared/network-grants";
|
|
36
39
|
import { emitYolo, onYolo, registerYoloFlags, yoloFromFlags, YOLO_STATUS } from "../src/shared/yolo";
|
|
37
40
|
|
|
38
41
|
export default function (pi: ExtensionAPI) {
|
|
@@ -40,6 +43,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
40
43
|
// `yoloFromFlags(pi)` below read the real CLI value.
|
|
41
44
|
registerYoloFlags(pi);
|
|
42
45
|
|
|
46
|
+
// A domain remembered from the Layer 2 guard prompt is written to
|
|
47
|
+
// sandbox.json by the guard; apply it to the running proxy here (ADR-023).
|
|
48
|
+
onDomainGrant(pi.events, applyNetworkGrant);
|
|
49
|
+
|
|
43
50
|
const localCwd = process.cwd();
|
|
44
51
|
const localBash = createBashTool(localCwd);
|
|
45
52
|
|
|
@@ -48,10 +55,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
48
55
|
let runtimeYolo = false;
|
|
49
56
|
let sandboxStarting: Promise<void> | null = null;
|
|
50
57
|
let latestCtx: ExtensionContext | undefined;
|
|
58
|
+
let latestBashCommand: string | undefined;
|
|
51
59
|
let activeCtx: { cwd: string; hasUI?: boolean; ui?: { select?: (t: string, o: string[], op?: { timeout?: number }) => Promise<string | undefined>; notify?: (m: string, l?: string) => void } } | undefined;
|
|
52
60
|
|
|
53
|
-
const persistAndReload = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowWrite", absPath, scope);
|
|
54
|
-
const persistAndReloadRead = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowRead", absPath, scope);
|
|
61
|
+
const persistAndReload = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowWrite", absPath, scope, networkDeps(localCwd));
|
|
62
|
+
const persistAndReloadRead = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowRead", absPath, scope, networkDeps(localCwd));
|
|
63
|
+
|
|
64
|
+
/** Layer 1 network ask wiring (ADR-023): the proxy asks for unknown hosts and applies grants live. */
|
|
65
|
+
const networkDeps = (cwd: string): NetworkAskDeps => ({
|
|
66
|
+
cwd,
|
|
67
|
+
getCtx: () => /* SAFETY: pi's runtime ctx carries ui.select/ui.notify; the local type names only the members used. */ activeCtx as unknown as NetworkAskCtx | undefined,
|
|
68
|
+
persist: (host, scope) => writeNetworkOverride(cwd, host, scope),
|
|
69
|
+
applyLive: applyNetworkGrant,
|
|
70
|
+
audit,
|
|
71
|
+
// Lazy getters so each proxy ask reads the live trust state and the most
|
|
72
|
+
// recent bash command. Without these, the values captured at initSandbox()
|
|
73
|
+
// would be stale.
|
|
74
|
+
get projectTrusted() { return latestCtx?.isProjectTrusted?.(); },
|
|
75
|
+
get command() { return latestBashCommand; },
|
|
76
|
+
});
|
|
55
77
|
|
|
56
78
|
/** Bring the bash sandbox down; safe when it was never up. */
|
|
57
79
|
const stopSandbox = async (): Promise<void> => {
|
|
@@ -77,7 +99,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
77
99
|
return;
|
|
78
100
|
}
|
|
79
101
|
try {
|
|
80
|
-
await initSandbox(ctx.cwd, config);
|
|
102
|
+
await initSandbox(ctx.cwd, config, networkDeps(ctx.cwd));
|
|
81
103
|
sandboxEnabled = true;
|
|
82
104
|
sandboxInitialized = true;
|
|
83
105
|
const networkCount = config.network?.allowedDomains?.length ?? 0;
|
|
@@ -140,9 +162,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
140
162
|
return { operations: createSandboxedBashOps({ ctx: activeCtx, onAlways: persistAndReload, onAlwaysRead: persistAndReloadRead }) };
|
|
141
163
|
});
|
|
142
164
|
|
|
165
|
+
// Capture the latest bash command so the network ask body can show it.
|
|
166
|
+
pi.on("tool_call", (event, _ctx) => {
|
|
167
|
+
if (event.toolName === "bash") {
|
|
168
|
+
const input = event.input as { command?: string };
|
|
169
|
+
if (typeof input.command === "string") latestBashCommand = input.command;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
143
173
|
pi.on("session_start", async (_event, ctx) => {
|
|
144
174
|
latestCtx = ctx;
|
|
145
175
|
activeCtx = /* SAFETY: pi's runtime ctx carries cwd/hasUI/ui; the local type only names the fields we use. */ ctx as unknown as typeof activeCtx;
|
|
176
|
+
clearNetworkSessionGrants();
|
|
146
177
|
setPiDeclinedTrust((ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() === false);
|
|
147
178
|
runtimeYolo = yoloFromFlags(pi) || loadConfig(ctx.cwd).mode === "yolo";
|
|
148
179
|
// Broadcast before any await: guard.ts consumes this on the same bus to
|
|
@@ -175,7 +206,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
175
206
|
return;
|
|
176
207
|
}
|
|
177
208
|
try {
|
|
178
|
-
await reloadSandbox(localCwd);
|
|
209
|
+
await reloadSandbox(localCwd, networkDeps(localCwd));
|
|
179
210
|
ctx.ui.notify("🔄 Sandbox reloaded from disk (global + project sandbox.json)", "info");
|
|
180
211
|
} catch (e) {
|
|
181
212
|
ctx.ui.notify(`Sandbox reload failed: ${e instanceof Error ? e.message : e}`, "error");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-permission-system",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.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",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"@anthropic-ai/sandbox-runtime": "^0.0.78"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
43
|
+
"@earendil-works/pi-tui": "*"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@commitlint/cli": "^21.2.3",
|
|
@@ -14,7 +14,7 @@ Wraps the `bash` tool with `sandbox-exec` (macOS) or `bubblewrap` (Linux). Block
|
|
|
14
14
|
- Filesystem writes outside `allowWrite`
|
|
15
15
|
- Filesystem reads of `denyRead` paths (defaults include `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.pi/agent`)
|
|
16
16
|
- Filesystem reads outside the project when `outsideProject.read` is `"ask"` or `"deny"` — when set, the home dir (and the project's parent) is fenced with `denyRead` and the project plus `allowWrite`/`outsideProject.allowRead` are re-exposed (ADR-014). With `"ask"`, a bash command that *plainly* names an outside path is confirmed with you **before it runs** (ADR-015): allow (`yes — this once`, or a persistent `always` grant) → the real, complete output; deny → the command is not run at all (no silently trimmed output). `install`/`npm` under `~/.pi/agent` are pre-allowed via `outsideProject.allowRead`, so they never prompt. Obfuscated reads still hit the fence and stay masked.
|
|
17
|
-
- Network egress to domains not in `allowedDomains`
|
|
17
|
+
- Network egress to domains not in `allowedDomains`: a host in neither list asks before the proxy decides (ADR-023) — `yes — this once` covers that bash command's connections to the host, `yes — for this session` lasts until the session ends, and `always` writes `overrides.allowDomains` and applies live. A host in `deniedDomains`, or `network.strictAllowlist: true`, is a hard deny that is never ask-able; headless blocks.
|
|
18
18
|
|
|
19
19
|
**Layer 2 — In-process tool guard**
|
|
20
20
|
Hooks `tool_call` for `read`, `grep`, `find`, `ls`, `write`, `edit`, `fetch_content`, `web_search`, `get_search_content`. Applies the same policy file as Layer 1. For `grep`, `find` and `ls` the search root is checked like a `read` path. A `tool_result` hook also removes `grep` output lines from denied files beneath an allowed root (for example a `.env` deep in the tree); approving the root does not approve those files. When a call is blocked, shows an interactive prompt with persistence options.
|
|
@@ -39,7 +39,8 @@ A higher layer only states what it changes; absent keys keep the layer below. A
|
|
|
39
39
|
"mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
|
|
40
40
|
"network": {
|
|
41
41
|
"allowedDomains": ["github.com", "*.github.com"], // empty = allow all
|
|
42
|
-
"deniedDomains": []
|
|
42
|
+
"deniedDomains": [], // hard deny, never ask-able
|
|
43
|
+
"strictAllowlist": false // true = deny unknown hosts instead of asking
|
|
43
44
|
},
|
|
44
45
|
"filesystem": {
|
|
45
46
|
"denyRead": ["~/.ssh", "~/.pi/agent"], // Layer 1 + 2: hard block (defaults add ~/.aws, ~/.gnupg)
|
|
@@ -83,9 +84,13 @@ When a normal (non-absolute-deny) call is blocked:
|
|
|
83
84
|
- **always for ALL projects** — whitelist in `~/.pi/agent/extensions/sandbox.json`
|
|
84
85
|
- **always for ALL projects (folder)** — whitelist parent directory globally
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
A **network** (domain) ask keeps the same rows, but the *unanswered* default flips (ADR-024): the footer reads `Default: Yes, just this once (15s)`, and a countdown that runs out counts as **allow once for this bash command**. `No` is still preselected (Enter blocks) and Esc still denies. A domain in `deniedDomains` (or `network.strictAllowlist: true`) never prompts.
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
Every prompt waits **15 seconds** by default and shows it live as `Default: … (Ns)` under the body. For file reads/writes no answer means the safe default: **block / deny**, and Esc blocks. For a network ask, an unanswered countdown means **allow once** (Esc still denies) — ADR-024. In the TUI the `Default:` line disappears as soon as you press ↑/↓ and the timer restarts, so the hint never competes with an active choice (ADR-031).
|
|
92
|
+
|
|
93
|
+
The same options apply to a bash command whose network request reaches a host outside `allowedDomains` (ADR-023), with the countdown default and live footer from ADR-024/ADR-031: `No` / `Yes, just this once` / `Yes, all in group for this session` / `Yes, always…`, and an untouched countdown counts as allow-once. Because the sandbox proxy asks per connection, `Allow (default)` covers that bash command rather than one connection — redirects and parallel connections to the same host do not re-prompt. A host in `deniedDomains`, or `network.strictAllowlist: true`, is blocked outright: the prompt never appears. A remembered host is written to `overrides.allowDomains` and takes effect immediately, so the waiting request completes without a retry. Prompts from both layers are serialized through one queue (`src/ui/ask-flow.ts`), so two unknown hosts cannot replace each other's selector and hang the waiting connection.
|
|
89
94
|
|
|
90
95
|
## Sensitive reads ask
|
|
91
96
|
|
|
@@ -129,7 +134,7 @@ Without restarting, `/permission-mode` → **YOLO** turns every layer off in one
|
|
|
129
134
|
|
|
130
135
|
## Common tasks
|
|
131
136
|
|
|
132
|
-
**Allow a new domain** — either answer "always" in the prompt when the block fires, or add it manually:
|
|
137
|
+
**Allow a new domain** — either answer "always" in the prompt when the block fires (from a bash command or a URL tool), or add it manually:
|
|
133
138
|
```json
|
|
134
139
|
// ~/.pi/agent/extensions/sandbox.json (global)
|
|
135
140
|
// or <cwd>/.pi/sandbox.json (project-local)
|
package/src/core/index.ts
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain pattern matching shared by both layers. Pure: no pi, no OS.
|
|
3
|
+
*
|
|
4
|
+
* `hostnameOf` parses a URL to its lowercase host; `domainMatches` implements
|
|
5
|
+
* the allow-list syntax used by `allowedDomains` / `deniedDomains` / grants:
|
|
6
|
+
* an exact host, or `*.domain` which also matches the apex. Port suffixes
|
|
7
|
+
* (`host:443`) are a sandbox-runtime config feature only; this matcher is
|
|
8
|
+
* hostname-only, which is what both layers store in policy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export function hostnameOf(url: string): string | null {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(url).hostname.toLowerCase();
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function domainMatches(host: string, pattern: string): boolean {
|
|
20
|
+
const p = pattern.toLowerCase();
|
|
21
|
+
if (p.startsWith("*.")) {
|
|
22
|
+
const suffix = p.slice(1); // ".npmjs.org"
|
|
23
|
+
return host === p.slice(2) || host.endsWith(suffix);
|
|
24
|
+
}
|
|
25
|
+
return host === p;
|
|
26
|
+
}
|
|
@@ -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,10 +9,16 @@ 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
|
-
import { loadConfig } from "./config";
|
|
15
|
+
import { loadConfig, projectTrusted } from "./config";
|
|
16
|
+
|
|
17
|
+
/** First whitespace-separated token of a bash command — used to name the binary in prompt titles. */
|
|
18
|
+
function firstWord(command: string): string {
|
|
19
|
+
return command.trim().split(/\s+/)[0] ?? command;
|
|
20
|
+
}
|
|
21
|
+
import { beginNetworkCommand, endNetworkCommand } from "./network-ask";
|
|
16
22
|
|
|
17
23
|
export interface SandboxedBashOpts {
|
|
18
24
|
ctx?: { cwd: string; hasUI?: boolean; ui?: { select?: (t: string, o: string[], op?: { timeout?: number }) => Promise<string | undefined>; notify?: (m: string, l?: string) => void } };
|
|
@@ -42,8 +48,12 @@ async function preflightSensitiveCommands(
|
|
|
42
48
|
const matched = matchedAskCommands(command, ask, cwd, homedir());
|
|
43
49
|
if (!matched.length) return undefined;
|
|
44
50
|
const ui = opts.ctx as AskCtx;
|
|
45
|
-
const body =
|
|
46
|
-
|
|
51
|
+
const body = [
|
|
52
|
+
`Command ${matched.join(", ")}`,
|
|
53
|
+
`Risk environment variables often hold API keys and tokens`,
|
|
54
|
+
`Note can only be allowed one run at a time`,
|
|
55
|
+
].join("\n");
|
|
56
|
+
const choice = await askMain(ui, `Let ${matched[0]} print your environment variables?`, body, { once: true, remember: false, icon: "cred" });
|
|
47
57
|
const subject = matched.join(", ");
|
|
48
58
|
if (choice === "once") {
|
|
49
59
|
auditL1({ subject, decision: "once", note: "sensitive-command", cwd });
|
|
@@ -75,7 +85,7 @@ async function preflightAskReads(
|
|
|
75
85
|
onData(Buffer.from(`\n❌ pi-sandbox: sensitive file read blocked: ${absPath}. Nothing was run — ask the user.\n`));
|
|
76
86
|
return { exitCode: 1 };
|
|
77
87
|
}
|
|
78
|
-
const choice = await askMain(ui, "
|
|
88
|
+
const choice = await askMain(ui, "Let this command read your .env file?", [`File ${absPath}`, `Why .env files usually hold passwords and API keys`, `Risk the AI will see any keys in it`, `Note can't be saved as a permanent rule (it holds secrets)`].join("\n"), { remember: false, icon: "cred" });
|
|
79
89
|
if (choice === "once" || choice === "session") {
|
|
80
90
|
auditL1({ subject: absPath, decision: choice, note: "ask-read", cwd });
|
|
81
91
|
continue;
|
|
@@ -100,7 +110,7 @@ async function preflightOutsideReads(
|
|
|
100
110
|
const once: string[] = [];
|
|
101
111
|
for (const absPath of outsideProjectReadCandidates(command, cwd, homedir(), cfg)) {
|
|
102
112
|
const ui = opts.ctx as AskCtx;
|
|
103
|
-
const main = await askMain(ui, "
|
|
113
|
+
const main = await askMain(ui, "Let this command read a file outside your project?", [`File ${absPath}`, `Command ${command}`, `Why files outside your project need your OK`].join("\n"), { session: true });
|
|
104
114
|
if (main === "once") {
|
|
105
115
|
auditL1({ subject: absPath, decision: "once", reason: "outside-project-read", note: "preflight", cwd });
|
|
106
116
|
once.push(absPath);
|
|
@@ -111,7 +121,7 @@ async function preflightOutsideReads(
|
|
|
111
121
|
if (main === "remember") {
|
|
112
122
|
const parent = dirname(absPath);
|
|
113
123
|
const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
|
|
114
|
-
const picked = await askRememberFile(ui, "
|
|
124
|
+
const picked = await askRememberFile(ui, "Always allow reading \u2014 what, and where?", [`File ${absPath}`, `Folder ${parent}/`, `Note the rule works for any command, not just ${firstWord(command)}`].join("\n"), absPath, folder, { icon: "save", untrusted: !projectTrusted(cwd) });
|
|
115
125
|
if (picked) {
|
|
116
126
|
subject = picked.folder ? parent : absPath;
|
|
117
127
|
scope = picked.scope;
|
|
@@ -239,13 +249,15 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
239
249
|
|
|
240
250
|
let offending: string | undefined;
|
|
241
251
|
let readDenied = false;
|
|
252
|
+
let writeDenied = false;
|
|
242
253
|
let hardDenied = false;
|
|
243
254
|
let hardWhy = "";
|
|
244
255
|
let outsideDenied = false;
|
|
245
256
|
let outsideMode: "allow" | "ask" | "deny" = "allow";
|
|
246
|
-
if (
|
|
257
|
+
if (isBlockedAccessError(outputTail)) {
|
|
247
258
|
// Relative paths resolve against the command's cwd: "./.env" is not "/.env".
|
|
248
259
|
offending = extractBlockedPath(outputTail, cwd, homedir());
|
|
260
|
+
writeDenied = isWriteBlockError(outputTail);
|
|
249
261
|
// A hard denyRead path (secret material): a write grant would not help,
|
|
250
262
|
// and "always" is not offered. The outside-project fence (ADR-014) is
|
|
251
263
|
// ask-able, so keep the two reasons apart.
|
|
@@ -255,7 +267,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
255
267
|
const hardPat = fsCfg.denyRead.find((pat) => matchesPolicyPattern(offending as string, pat, cwd, homedir()));
|
|
256
268
|
hardDenied = hardPat !== undefined;
|
|
257
269
|
hardWhy = hardPat ? `denyRead matched "${hardPat}"` : "denyRead";
|
|
258
|
-
outsideDenied = outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
|
|
270
|
+
outsideDenied = !writeDenied && outsideProjectReadDenied(offending, cwd, homedir(), fsCfg);
|
|
259
271
|
outsideMode = outsideProjectMode(fsCfg);
|
|
260
272
|
}
|
|
261
273
|
readDenied = hardDenied || outsideDenied;
|
|
@@ -265,7 +277,8 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
265
277
|
let hint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
|
|
266
278
|
if (offending) {
|
|
267
279
|
let why = "";
|
|
268
|
-
if (hardDenied) why = " (
|
|
280
|
+
if (writeDenied && !hardDenied) why = " (not under an allowWrite root: writes outside it are read-only)";
|
|
281
|
+
else if (hardDenied) why = " (denyRead: reading it is blocked by policy)";
|
|
269
282
|
else if (outsideDenied) why = " (outside the project: reading it is gated by policy)";
|
|
270
283
|
hint += ` Path: ${offending}${why}\n`;
|
|
271
284
|
}
|
|
@@ -299,11 +312,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
299
312
|
const absPath = offending;
|
|
300
313
|
try {
|
|
301
314
|
const ui = opts.ctx as AskCtx;
|
|
302
|
-
const main = await askMain(ui, "
|
|
315
|
+
const main = await askMain(ui, "Let this command read a file outside your project?", [`File ${absPath}`, `Command ${command}`, `Why outside your project, not on your allowed list`].join("\n"), { session: true });
|
|
303
316
|
if (main === "remember") {
|
|
304
317
|
const parent = dirname(absPath);
|
|
305
318
|
const folder = isSafeFolderGrant(parent, homedir()) ? parent : null;
|
|
306
|
-
const picked = await askRememberFile(ui, "
|
|
319
|
+
const picked = await askRememberFile(ui, "Always allow reading \u2014 what, and where?", [`File ${absPath}`, `Folder ${parent}/`, `Note the rule works for any command, not just ${firstWord(command)}`].join("\n"), absPath, folder, { icon: "save", untrusted: !projectTrusted(cwd) });
|
|
307
320
|
if (picked) {
|
|
308
321
|
const subject = picked.folder ? parent : absPath;
|
|
309
322
|
try {
|
|
@@ -327,9 +340,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
327
340
|
onData(Buffer.from(decisionHint));
|
|
328
341
|
} else if (offending && readDenied) {
|
|
329
342
|
const why = hardDenied ? hardWhy : "outside the project";
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
343
|
+
const action: "Read" | "Write" = writeDenied ? "Write" : "Read";
|
|
344
|
+
const outcome = action === "Write" ? "written" : "read";
|
|
345
|
+
auditL1({ subject: offending, decision: readDenied && !writeDenied ? "read-denied" : "write-denied", reason: why, cwd });
|
|
346
|
+
opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a ${action.toLowerCase()} of ${offending} (${why}). Edit sandbox.json if that is wrong.`, "warning");
|
|
347
|
+
decisionHint = `\n${blockedLine(action, why, outcome)}\n`;
|
|
333
348
|
onData(Buffer.from(decisionHint));
|
|
334
349
|
} else if (offending && opts?.ctx?.hasUI && opts.ctx.ui?.select && opts.onAlways) {
|
|
335
350
|
const absPath = offending;
|
|
@@ -337,7 +352,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
337
352
|
const alreadyGranted = writeOnce.includes(parentDir);
|
|
338
353
|
try {
|
|
339
354
|
const ui = opts.ctx as AskCtx;
|
|
340
|
-
const main = await askMain(ui,
|
|
355
|
+
const main = await askMain(ui, `Let ${firstWord(command)} save files in ${parentDir}/?`, [`File ${absPath}`, `Command ${command}`, `Why outside your project, not on your allowed list`].join("\n"), { session: true, once: !alreadyGranted });
|
|
341
356
|
if (main === "once") {
|
|
342
357
|
auditL1({ subject: absPath, granularity: "folder", original: absPath, decision: "once", scope: "invocation", cwd: opts.ctx.cwd, note: "write-once" });
|
|
343
358
|
opts.ctx.ui?.notify?.(`pi-sandbox: allowed ${absPath} once — re-running the command`, "warning");
|
|
@@ -347,7 +362,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
347
362
|
}
|
|
348
363
|
if (main === "remember") {
|
|
349
364
|
const folder = isSafeFolderGrant(parentDir, homedir()) ? parentDir : null;
|
|
350
|
-
const picked = await askRememberFile(ui, "
|
|
365
|
+
const picked = await askRememberFile(ui, "Always allow saving \u2014 what, and where?", [`File ${absPath}`, `Folder ${parentDir}/`, `Note the rule works for any command, not just ${firstWord(command)}`].join("\n"), absPath, folder, { icon: "save", untrusted: !projectTrusted(cwd) });
|
|
351
366
|
if (picked) {
|
|
352
367
|
const subject = picked.folder ? parentDir : absPath;
|
|
353
368
|
try {
|
|
@@ -370,6 +385,11 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
370
385
|
decisionHint = `\n${blockedLine("Write", "user denied", "written")}\n`;
|
|
371
386
|
}
|
|
372
387
|
onData(Buffer.from(decisionHint));
|
|
388
|
+
} else if (offending && writeDenied) {
|
|
389
|
+
// No UI to prompt with: report the fence and the path to the model.
|
|
390
|
+
auditL1({ subject: offending, decision: "write-denied", reason: "not under any allowWrite root", cwd });
|
|
391
|
+
opts?.ctx?.ui?.notify?.(`pi-sandbox: bash was refused a write of ${offending} (not under any allowWrite root).`, "warning");
|
|
392
|
+
onData(Buffer.from(`\n${blockedLine("Write", "not under any allowWrite root", "written")}\n`));
|
|
373
393
|
}
|
|
374
394
|
|
|
375
395
|
if (signal?.aborted) {
|
|
@@ -383,7 +403,14 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
383
403
|
});
|
|
384
404
|
};
|
|
385
405
|
|
|
386
|
-
|
|
406
|
+
// "Allow once" network grants cover this command's connections to a host;
|
|
407
|
+
// the finally releases them however the command ends (abort/timeout too).
|
|
408
|
+
beginNetworkCommand();
|
|
409
|
+
try {
|
|
410
|
+
return await attempt([]);
|
|
411
|
+
} finally {
|
|
412
|
+
endNetworkCommand();
|
|
413
|
+
}
|
|
387
414
|
},
|
|
388
415
|
};
|
|
389
416
|
}
|