pi-better-sandbox 0.2.0 → 0.4.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 +63 -22
- package/commands.ts +31 -3
- package/files.ts +22 -9
- package/index.ts +72 -16
- package/package.json +2 -2
- package/permission-settings.ts +44 -0
- package/permissions-page.ts +177 -0
- package/permissions.ts +50 -0
- package/shared-sandbox-core.ts +283 -11
- package/state.ts +54 -8
- package/status.ts +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-better-sandbox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sandbox permissions for Pi's foreground tools and detached subagents.
|
|
4
4
|
|
|
5
5
|
It is installed by default with [`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme), and can be installed on its own:
|
|
6
6
|
|
|
@@ -9,19 +9,37 @@ pi install npm:pi-better-sandbox
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Either way you keep starting Pi the way you always have — `pi`. There is
|
|
12
|
-
no launcher or wrapper command.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
run inside an OS sandbox that lets them write only under the directory you
|
|
16
|
-
launched Pi from, and the built-in `write` and `edit` tools are held to the same
|
|
17
|
-
policy.
|
|
12
|
+
no launcher or wrapper command. Main starts inactive; Subagents start confined.
|
|
13
|
+
Open `/sandbox` to change either column, or use `/sandbox on` for the current
|
|
14
|
+
Main session. **Save as defaults** persists both profiles.
|
|
18
15
|
|
|
16
|
+
```text
|
|
17
|
+
Sandbox permissions Main Subagents
|
|
18
|
+
|
|
19
|
+
Sandbox Off On
|
|
20
|
+
|
|
21
|
+
Project files - Read / write
|
|
22
|
+
Outside project - Read
|
|
23
|
+
Stored credentials - Read
|
|
24
|
+
Run commands & applications - On
|
|
25
|
+
Network access - On
|
|
26
|
+
|
|
27
|
+
↑↓ Select row ←→ Select column Space Change
|
|
28
|
+
Save as defaults
|
|
19
29
|
```
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
|
|
31
|
+
File permissions cycle through Off, Read, and Read / write. Other rows toggle
|
|
32
|
+
Off/On. Detail cells under an Off sandbox display a dimmed `-` and cannot be
|
|
33
|
+
changed; their values return when the sandbox is enabled again. Outside project
|
|
34
|
+
covers paths outside the assigned root without custom folder lists. Protected
|
|
35
|
+
paths remain write-denied regardless of the broad file settings.
|
|
36
|
+
|
|
37
|
+
**Stored credentials currently means known credential files.** It covers SSH,
|
|
38
|
+
AWS, GitHub CLI, Google Cloud CLI, Azure, Kubernetes, Docker, npm, netrc, Git
|
|
39
|
+
credentials, and Pi's file-based auth. These rules override ordinary file access.
|
|
40
|
+
OS vault services such as Keychain and Secret Service, and tokens inherited in
|
|
41
|
+
environment variables, are excluded. Read / write may be needed by a CLI that
|
|
42
|
+
refreshes a token or updates its credential database.
|
|
25
43
|
|
|
26
44
|
For shell commands the denial is done by the kernel, not by inspecting command
|
|
27
45
|
text: macOS uses Seatbelt (`sandbox-exec`) and Linux uses Bubblewrap (`bwrap`).
|
|
@@ -36,16 +54,18 @@ nothing behind on disk.
|
|
|
36
54
|
|
|
37
55
|
## What is confined, and what is not
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
Selected file and network rules apply to confined commands. Main's model
|
|
58
|
+
connection remains outside the tool sandbox. A detached subagent's OS sandbox
|
|
59
|
+
surrounds its whole runtime: until provider networking is isolated, launching a
|
|
60
|
+
subagent with Network access Off is refused with an explanation. Run commands &
|
|
61
|
+
applications Off prevents new shell, application, background-task, and subagent
|
|
62
|
+
launches; in-process file tools can still operate according to their file rules.
|
|
42
63
|
|
|
43
|
-
|
|
44
|
-
those:
|
|
64
|
+
Permissions cover the integrated first-party execution paths:
|
|
45
65
|
|
|
46
66
|
- Pi's built-in `bash` tool.
|
|
47
67
|
- User-entered `!` and `!!` commands.
|
|
48
|
-
- Pi's built-in `write
|
|
68
|
+
- Pi's built-in `read`, `write`, and `edit` tools.
|
|
49
69
|
- Local [`pi-better-background-tasks`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-background-tasks#readme)
|
|
50
70
|
spawns and watches, which capture this policy at launch.
|
|
51
71
|
- [`pi-better-subagents`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-subagents#readme)
|
|
@@ -56,6 +76,11 @@ those:
|
|
|
56
76
|
- Pi's own process.
|
|
57
77
|
- `pi.exec` calls made by extensions.
|
|
58
78
|
- Unrelated third-party extension code.
|
|
79
|
+
- Remote filesystem operations: local permissions cannot constrain the remote
|
|
80
|
+
host. With Main sandbox enabled, the dedicated `remote_bash` and structured
|
|
81
|
+
SSH background launchers are blocked because their local SSH client does not
|
|
82
|
+
yet use the confinement wrapper. Run `ssh` through confined `bash` to apply
|
|
83
|
+
local file, credential, and network permissions to the SSH client.
|
|
59
84
|
- Another first-party surface's control plane. Each surface denies its own —
|
|
60
85
|
the files naming what it will run next — but not every other surface's, so
|
|
61
86
|
confinement is per surface rather than global.
|
|
@@ -71,7 +96,7 @@ operations underneath them are replaced.
|
|
|
71
96
|
## Commands
|
|
72
97
|
|
|
73
98
|
```text
|
|
74
|
-
/sandbox
|
|
99
|
+
/sandbox open the permission table (text status outside TUI)
|
|
75
100
|
/sandbox on enable protection for operations started from now on
|
|
76
101
|
/sandbox off turn protection off for this session (interactive confirmation)
|
|
77
102
|
/sandbox default on persist opt-in and enable it now
|
|
@@ -164,9 +189,11 @@ in your rule set but is held out in that project, with a message saying so.
|
|
|
164
189
|
## Lifecycle
|
|
165
190
|
|
|
166
191
|
The foreground sandbox is inactive by default. Session overrides do not survive
|
|
167
|
-
startup, new session, resume, fork, or reload.
|
|
168
|
-
|
|
169
|
-
|
|
192
|
+
startup, new session, resume, fork, or reload. Save as defaults writes both
|
|
193
|
+
profiles to `~/.pi/agent/extensions/pi-better-sandbox-permissions.json` (or the
|
|
194
|
+
corresponding `$PI_CODING_AGENT_DIR`). Existing activation preferences in
|
|
195
|
+
`pi-better-sandbox-preferences.json` migrate when no profile file exists.
|
|
196
|
+
`/sandbox default on|off` remains available and updates Main's saved switch.
|
|
170
197
|
|
|
171
198
|
Toggles apply to operations launched after the change. A command already running
|
|
172
199
|
keeps the policy it launched with.
|
|
@@ -198,6 +225,20 @@ that exact file, and an alias pointing at a denied file is denied too.
|
|
|
198
225
|
| Linux | Bubblewrap (`bwrap`) | install `bubblewrap` |
|
|
199
226
|
| Other | none | protected commands are blocked |
|
|
200
227
|
|
|
228
|
+
A detached subagent gets a private session/temp directory for Pi's runtime
|
|
229
|
+
state. This directory stays writable even with Outside project set to Read or
|
|
230
|
+
Off; other runs' state and the parent's launch metadata are not included.
|
|
231
|
+
Read access to system executable and library directories, device I/O, and root
|
|
232
|
+
directory metadata/listing remains available so a process can start. On macOS,
|
|
233
|
+
the allowance excludes the broad `/System/Volumes` tree.
|
|
234
|
+
|
|
235
|
+
Linux currently refuses combinations it cannot safely mount: hiding the
|
|
236
|
+
project or credential files inside a visible whole-filesystem bind, writing
|
|
237
|
+
credential stores under a read-only outside root, and a writable outside root
|
|
238
|
+
with protected paths. These launch errors preserve the selected restrictions.
|
|
239
|
+
The macOS permission combinations are covered by real-kernel tests; Linux
|
|
240
|
+
mount behavior requires a Linux runner with Bubblewrap and user namespaces.
|
|
241
|
+
|
|
201
242
|
## For other extensions
|
|
202
243
|
|
|
203
244
|
The effective policy is published as a frozen snapshot on Pi's extension event
|
package/commands.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
15
16
|
|
|
16
17
|
import {
|
|
17
18
|
DenyRuleError,
|
|
@@ -26,7 +27,7 @@ import type { ForegroundSandboxController, ForegroundSandboxStatus } from "./sta
|
|
|
26
27
|
export const SANDBOX_COMMAND_NAME = "sandbox";
|
|
27
28
|
|
|
28
29
|
export const SANDBOX_COMMAND_DESCRIPTION =
|
|
29
|
-
"
|
|
30
|
+
"Configure Main and Subagents sandbox permissions, activation defaults, and protected paths";
|
|
30
31
|
|
|
31
32
|
const USAGE = [
|
|
32
33
|
"Usage:",
|
|
@@ -79,6 +80,7 @@ export type SandboxCommandDeps = {
|
|
|
79
80
|
onStateChange: (status: ForegroundSandboxStatus) => void;
|
|
80
81
|
/** Persist a default and apply it to the current session. */
|
|
81
82
|
setDefault: (enabled: boolean) => ForegroundSandboxStatus;
|
|
83
|
+
openPermissions?: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
82
84
|
};
|
|
83
85
|
|
|
84
86
|
/** Build the `/sandbox` handler. Exported so its behaviour is directly testable. */
|
|
@@ -87,6 +89,7 @@ export function createSandboxCommandHandler({
|
|
|
87
89
|
denyRules,
|
|
88
90
|
onStateChange,
|
|
89
91
|
setDefault,
|
|
92
|
+
openPermissions,
|
|
90
93
|
}: SandboxCommandDeps) {
|
|
91
94
|
return async function handleSandboxCommand(
|
|
92
95
|
args: string,
|
|
@@ -100,6 +103,10 @@ export function createSandboxCommandHandler({
|
|
|
100
103
|
const rest = trimmed.slice(verb.length).trim();
|
|
101
104
|
|
|
102
105
|
if (subcommand === "") {
|
|
106
|
+
if (openPermissions && ctx.mode === "tui") {
|
|
107
|
+
await openPermissions(ctx);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
103
110
|
ctx.ui.notify(formatSandboxReport(controller.status()), "info");
|
|
104
111
|
return;
|
|
105
112
|
}
|
|
@@ -256,8 +263,22 @@ function announce(ctx: ExtensionCommandContext, change: () => DenyRuleReport): v
|
|
|
256
263
|
const SUBCOMMANDS = ["on", "off", "default", "deny", "rules"] as const;
|
|
257
264
|
const DENY_ACTIONS = ["list", "add", "remove", "reset"] as const;
|
|
258
265
|
|
|
266
|
+
const COMPLETION_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
|
267
|
+
on: "Enable confinement for this session",
|
|
268
|
+
off: "Disable confinement for this session",
|
|
269
|
+
default: "Change the persistent activation default",
|
|
270
|
+
"default on": "Enable confinement by default",
|
|
271
|
+
"default off": "Disable confinement by default",
|
|
272
|
+
deny: "Manage write-denied paths",
|
|
273
|
+
"deny list": "Show configured write-denied paths",
|
|
274
|
+
"deny add": "Add a write-denied path",
|
|
275
|
+
"deny remove": "Remove a write-denied path",
|
|
276
|
+
"deny reset": "Restore packaged write-deny defaults",
|
|
277
|
+
rules: "Open the write-deny rule manager",
|
|
278
|
+
};
|
|
279
|
+
|
|
259
280
|
/** Argument completions for `/sandbox`, including the `deny` actions. */
|
|
260
|
-
export function sandboxArgumentCompletions(argumentPrefix: string) {
|
|
281
|
+
export function sandboxArgumentCompletions(argumentPrefix: string): AutocompleteItem[] {
|
|
261
282
|
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
262
283
|
const denyPrefix = /^deny(\s|$)/.test(prefix) ? prefix.replace(/^deny\s*/, "") : undefined;
|
|
263
284
|
const defaultPrefix = /^default(\s|$)/.test(prefix)
|
|
@@ -275,5 +296,12 @@ export function sandboxArgumentCompletions(argumentPrefix: string) {
|
|
|
275
296
|
.map((value) => `default ${value}`)
|
|
276
297
|
: SUBCOMMANDS.filter((value) => value.startsWith(prefix));
|
|
277
298
|
|
|
278
|
-
return values.map((value) =>
|
|
299
|
+
return values.map((value) => {
|
|
300
|
+
const description = COMPLETION_DESCRIPTIONS[value];
|
|
301
|
+
return {
|
|
302
|
+
value,
|
|
303
|
+
label: value,
|
|
304
|
+
...(description === undefined ? {} : { description }),
|
|
305
|
+
};
|
|
306
|
+
});
|
|
279
307
|
}
|
package/files.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* be re-targeted between the check and the syscall: the guard and the `fs` call
|
|
17
17
|
* are the same operation.
|
|
18
18
|
*
|
|
19
|
-
* Reads
|
|
20
|
-
*
|
|
19
|
+
* Reads and mutations use the same canonical file-permission decisions as the
|
|
20
|
+
* kernel policy; protected-path write denials remain stricter than broad grants.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { constants } from "node:fs";
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type CompiledSandboxWritePolicy,
|
|
35
35
|
compileWritePolicy,
|
|
36
36
|
evaluateWriteAccess,
|
|
37
|
+
evaluateReadAccess,
|
|
37
38
|
type SandboxSeams,
|
|
38
39
|
type SandboxWritePolicy,
|
|
39
40
|
type WriteAccessDecision,
|
|
@@ -77,9 +78,9 @@ function explainDenial(
|
|
|
77
78
|
): string {
|
|
78
79
|
const attempt = kind === "directory" ? "create directory" : "write";
|
|
79
80
|
const refused = `Foreground sandbox refused to ${attempt} ${decision.path}; nothing was changed on disk.`;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
if (decision.reason === "outside-writable-root") return `${refused} Writes are confined to ${policy.writableRoot}.`;
|
|
82
|
+
if (decision.reason === "write-denied") return `${refused} ${decision.deniedBy} is a write-denied path.`;
|
|
83
|
+
return `${refused} The selected file permissions do not allow this write.`;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
/**
|
|
@@ -95,7 +96,7 @@ export type ForegroundWriteGuard = (absolutePath: string, kind?: MutationKind) =
|
|
|
95
96
|
|
|
96
97
|
/** Identity of a compiled policy, so it is recompiled on change and not per mutation. */
|
|
97
98
|
function policyKey(policy: SandboxWritePolicy): string {
|
|
98
|
-
return JSON.stringify(
|
|
99
|
+
return JSON.stringify(policy);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
/**
|
|
@@ -137,6 +138,17 @@ export function createForegroundWriteGuard(
|
|
|
137
138
|
};
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/** Enforce reads in Pi's in-process file tools using the same canonical policy. */
|
|
142
|
+
export function createForegroundReadGuard(controller: ForegroundSandboxController, seams: SandboxSeams = {}) {
|
|
143
|
+
return (absolutePath: string): string => {
|
|
144
|
+
const plan = controller.requireLaunchPlan();
|
|
145
|
+
if (!plan.confined) return absolutePath;
|
|
146
|
+
const decision = evaluateReadAccess(absolutePath, compileWritePolicy(plan.policy, seams), seams);
|
|
147
|
+
if (!decision.allowed) throw new Error(`Foreground sandbox refused to read ${decision.path}: ${decision.reason}.`);
|
|
148
|
+
return decision.path;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
/** Pi's own default local backends, which the guarded operations delegate to. */
|
|
141
153
|
const localWriteOperations: WriteOperations = {
|
|
142
154
|
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
|
@@ -189,18 +201,19 @@ export function createSandboxedWriteOperations(
|
|
|
189
201
|
*
|
|
190
202
|
* `access` is Pi's own pre-flight gate for `edit`, so guarding it refuses a
|
|
191
203
|
* denied target before the file is read or a diff is computed; `writeFile` is
|
|
192
|
-
* guarded because it is the mutation. `readFile`
|
|
193
|
-
*
|
|
204
|
+
* guarded because it is the mutation. `readFile` also checks the selected read
|
|
205
|
+
* permissions before accessing content.
|
|
194
206
|
*/
|
|
195
207
|
export function createSandboxedEditOperations(
|
|
196
208
|
controller: ForegroundSandboxController,
|
|
197
209
|
options: SandboxedEditOperationsOptions = {},
|
|
198
210
|
): EditOperations {
|
|
199
211
|
const assertWritable = createForegroundWriteGuard(controller, options);
|
|
212
|
+
const assertReadable = createForegroundReadGuard(controller, options);
|
|
200
213
|
const local = options.localOperations ?? localEditOperations;
|
|
201
214
|
|
|
202
215
|
return {
|
|
203
|
-
readFile: (absolutePath) => local.readFile(absolutePath),
|
|
216
|
+
readFile: (absolutePath) => local.readFile(assertReadable(absolutePath)),
|
|
204
217
|
async access(absolutePath) {
|
|
205
218
|
return local.access(assertWritable(absolutePath));
|
|
206
219
|
},
|
package/index.ts
CHANGED
|
@@ -5,17 +5,19 @@
|
|
|
5
5
|
* keep starting Pi with plain `pi`. While enabled, the built-in `bash` tool and
|
|
6
6
|
* user-entered `!` / `!!` commands run under macOS Seatbelt or Linux Bubblewrap
|
|
7
7
|
* with one writable root — the canonical directory Pi was launched from — and
|
|
8
|
-
* the packaged write-denied paths carved back out of it.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* in-process containment check instead. Reads and network are untouched.
|
|
8
|
+
* the packaged write-denied paths carved back out of it. Selected file,
|
|
9
|
+
* credential-file, command, and network permissions apply to protected tools.
|
|
10
|
+
* In-process file tools use the same canonical policy as spawned commands.
|
|
12
11
|
*
|
|
13
12
|
* This is a tool-execution sandbox. Pi's own process, `pi.exec` calls, and
|
|
14
13
|
* unrelated third-party extension code are not confined by it.
|
|
15
14
|
*/
|
|
16
15
|
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { resolve } from "node:path";
|
|
17
18
|
import {
|
|
18
19
|
createBashToolDefinition,
|
|
20
|
+
createReadToolDefinition,
|
|
19
21
|
createEditToolDefinition,
|
|
20
22
|
createWriteToolDefinition,
|
|
21
23
|
SettingsManager,
|
|
@@ -37,8 +39,12 @@ import {
|
|
|
37
39
|
import {
|
|
38
40
|
createSandboxedEditOperations,
|
|
39
41
|
createSandboxedWriteOperations,
|
|
42
|
+
createForegroundReadGuard,
|
|
40
43
|
} from "./files.ts";
|
|
41
|
-
import {
|
|
44
|
+
import { writeSandboxDefault } from "./preferences.ts";
|
|
45
|
+
import { readPermissionSettings, writePermissionSettings } from "./permission-settings.ts";
|
|
46
|
+
import { defaultSandboxPermissions } from "./permissions.ts";
|
|
47
|
+
import { openPermissionsPage } from "./permissions-page.ts";
|
|
42
48
|
import { createSandboxedBashOperations } from "./shell.ts";
|
|
43
49
|
import { footerTone, formatFooterStatus } from "./status.ts";
|
|
44
50
|
import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
|
|
@@ -69,6 +75,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
69
75
|
// operations run inside that queue, which is where the enforcement belongs.
|
|
70
76
|
const writeOperations = createSandboxedWriteOperations(controller);
|
|
71
77
|
const editOperations = createSandboxedEditOperations(controller);
|
|
78
|
+
const assertReadable = createForegroundReadGuard(controller);
|
|
72
79
|
|
|
73
80
|
// `cwd` is what these tools resolve a relative `path` against, so it has to
|
|
74
81
|
// be the directory Pi itself resolves against. Registration is re-run when
|
|
@@ -80,9 +87,42 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
80
87
|
fileToolCwd = cwd;
|
|
81
88
|
pi.registerTool(createWriteToolDefinition(cwd, { operations: writeOperations }));
|
|
82
89
|
pi.registerTool(createEditToolDefinition(cwd, { operations: editOperations }));
|
|
90
|
+
const read = createReadToolDefinition(cwd);
|
|
91
|
+
pi.registerTool({
|
|
92
|
+
...read,
|
|
93
|
+
execute: (id, params, signal, update, ctx) => {
|
|
94
|
+
const path = params.path.replace(/^@/, "");
|
|
95
|
+
const expanded = path === "~" ? homedir() : path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : resolve(cwd, path);
|
|
96
|
+
return read.execute(id, { ...params, path: assertReadable(expanded) }, signal, update, ctx);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
83
99
|
};
|
|
84
100
|
registerFileTools(process.cwd());
|
|
85
101
|
|
|
102
|
+
pi.on("tool_call", (event) => {
|
|
103
|
+
const status = controller.status();
|
|
104
|
+
if (status.state === "inactive" || status.state === "disabled") return;
|
|
105
|
+
const permissions = status.permissions;
|
|
106
|
+
if (!permissions) return;
|
|
107
|
+
const name = event.toolName;
|
|
108
|
+
const action = (event.input as { action?: string }).action;
|
|
109
|
+
const launch = ["bash", "powershell", "remote_bash", "subagent_spawn", "subagent_spawn_batch", "bg_task_spawn", "bg_task_watch"].includes(name) ||
|
|
110
|
+
(name === "bg_task" && (action === "spawn" || action === "watch"));
|
|
111
|
+
if (!permissions.commands && (launch || name === "grep" || name === "find")) {
|
|
112
|
+
return { block: true, reason: "Sandbox: Run commands & applications is Off. Change it in /sandbox to launch work." };
|
|
113
|
+
}
|
|
114
|
+
if (!permissions.network && (["web_search", "web_fetch", "firecrawl_scrape", "firecrawl_extract", "remote_bash", "mcp", "mcpScript"].includes(name) || name.startsWith("mcp__") ||
|
|
115
|
+
(launch && Boolean((event.input as { ssh?: unknown }).ssh)))) {
|
|
116
|
+
return { block: true, reason: "Sandbox: Network access is Off." };
|
|
117
|
+
}
|
|
118
|
+
if (name === "powershell" || name === "remote_bash") {
|
|
119
|
+
return { block: true, reason: `Sandbox: ${name} is not a confined execution surface; use bash (including ssh through bash).` };
|
|
120
|
+
}
|
|
121
|
+
if (status.readPolicy === "restricted" && ["grep", "find", "ls"].includes(name)) {
|
|
122
|
+
return { block: true, reason: "Sandbox: use the guarded read tool or a confined bash command for restricted file access." };
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
86
126
|
let paintFooter: ((status: ForegroundSandboxStatus) => void) | undefined;
|
|
87
127
|
|
|
88
128
|
const announce = (status: ForegroundSandboxStatus): void => {
|
|
@@ -110,19 +150,25 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
110
150
|
);
|
|
111
151
|
};
|
|
112
152
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
let defaultEnabled = false;
|
|
153
|
+
// Re-read saved profiles on each session. Malformed policy blocks protected
|
|
154
|
+
// operations rather than silently widening a restricted session.
|
|
155
|
+
let settings = defaultSandboxPermissions();
|
|
117
156
|
try {
|
|
118
|
-
|
|
157
|
+
settings = readPermissionSettings();
|
|
119
158
|
} catch (error) {
|
|
120
|
-
ctx.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
159
|
+
controller.beginSession(ctx.cwd, true);
|
|
160
|
+
const message = `Sandbox permissions could not be loaded: ${error instanceof Error ? error.message : String(error)}`;
|
|
161
|
+
settings.main.enabled = true;
|
|
162
|
+
settings.main.commands = false;
|
|
163
|
+
settings.subagents.commands = false;
|
|
164
|
+
controller.setPermissionSettings(settings);
|
|
165
|
+
announce(controller.block(message));
|
|
166
|
+
ctx.ui.notify(message, "error");
|
|
167
|
+
return;
|
|
124
168
|
}
|
|
125
|
-
controller.beginSession(ctx.cwd,
|
|
169
|
+
controller.beginSession(ctx.cwd, settings.main.enabled);
|
|
170
|
+
controller.setPermissionSettings(settings);
|
|
171
|
+
controller.applyDefault(settings.main.enabled);
|
|
126
172
|
|
|
127
173
|
// Then the rules are re-read and re-resolved, because the same global
|
|
128
174
|
// template set means different absolute paths in a different project.
|
|
@@ -160,8 +206,18 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
160
206
|
denyRules,
|
|
161
207
|
onStateChange: announce,
|
|
162
208
|
setDefault: (enabled) => {
|
|
209
|
+
const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
|
|
210
|
+
settings.main.enabled = enabled;
|
|
211
|
+
writePermissionSettings(settings);
|
|
163
212
|
writeSandboxDefault(enabled ? "on" : "off");
|
|
164
|
-
return controller.
|
|
213
|
+
return controller.setPermissionSettings(settings);
|
|
214
|
+
},
|
|
215
|
+
openPermissions: async (ctx) => {
|
|
216
|
+
await openPermissionsPage(ctx, {
|
|
217
|
+
getConfig: () => controller.permissionSettings() ?? defaultSandboxPermissions(),
|
|
218
|
+
change: (settings) => announce(controller.setPermissionSettings(settings)),
|
|
219
|
+
save: (settings) => writePermissionSettings(settings),
|
|
220
|
+
});
|
|
165
221
|
},
|
|
166
222
|
}),
|
|
167
223
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-sandbox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.ts",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Versioned storage for the permission table; does not activate enforcement. */
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { defaultSandboxPermissions, parseSandboxPermissions, type SandboxPermissionSettings } from "./permissions.ts";
|
|
7
|
+
import { readSandboxDefault, type SandboxPreferenceSeams } from "./preferences.ts";
|
|
8
|
+
|
|
9
|
+
export function permissionSettingsPath(seams: SandboxPreferenceSeams = {}): string {
|
|
10
|
+
return join((seams.agentDir ?? getAgentDir)(), "extensions", "pi-better-sandbox-permissions.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readPermissionSettings(seams: SandboxPreferenceSeams = {}): SandboxPermissionSettings {
|
|
14
|
+
let raw: string;
|
|
15
|
+
try {
|
|
16
|
+
raw = readFileSync(permissionSettingsPath(seams), "utf8");
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
19
|
+
const settings = defaultSandboxPermissions();
|
|
20
|
+
settings.main.enabled = readSandboxDefault(seams) === "on";
|
|
21
|
+
return settings;
|
|
22
|
+
}
|
|
23
|
+
const parsed: unknown = JSON.parse(raw);
|
|
24
|
+
if (!parsed || typeof parsed !== "object" || (parsed as { version?: unknown }).version !== 1) {
|
|
25
|
+
throw new Error("Unsupported sandbox permissions file version.");
|
|
26
|
+
}
|
|
27
|
+
return parseSandboxPermissions((parsed as { permissions?: unknown }).permissions);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writePermissionSettings(
|
|
31
|
+
settings: SandboxPermissionSettings,
|
|
32
|
+
seams: SandboxPreferenceSeams = {},
|
|
33
|
+
): void {
|
|
34
|
+
const permissions = parseSandboxPermissions(settings);
|
|
35
|
+
const path = permissionSettingsPath(seams);
|
|
36
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
37
|
+
const pending = `${path}.${randomUUID()}.tmp`;
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(pending, JSON.stringify({ version: 1, permissions }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
40
|
+
renameSync(pending, path);
|
|
41
|
+
} finally {
|
|
42
|
+
rmSync(pending, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { defaultSandboxPermissions, type FileAccess, type SandboxPermissionProfile as PermissionProfile, type SandboxPermissionSettings as PermissionSettings } from "./permissions.ts";
|
|
5
|
+
export type { PermissionProfile, PermissionSettings };
|
|
6
|
+
|
|
7
|
+
export interface PermissionPageHandlers {
|
|
8
|
+
getConfig(): PermissionSettings;
|
|
9
|
+
change(settings: PermissionSettings): void | Promise<void>;
|
|
10
|
+
save(settings: PermissionSettings): void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PERMISSION_SETTINGS = defaultSandboxPermissions();
|
|
14
|
+
|
|
15
|
+
const rows = [
|
|
16
|
+
{ label: "Sandbox", key: "enabled" },
|
|
17
|
+
{ label: "Project files", key: "projectFiles" },
|
|
18
|
+
{ label: "Outside project", key: "outsideProject" },
|
|
19
|
+
{ label: "Stored credentials", key: "storedCredentials" },
|
|
20
|
+
{ label: "Run commands & applications", key: "commands" },
|
|
21
|
+
{ label: "Network access", key: "network" },
|
|
22
|
+
] as const;
|
|
23
|
+
const fileValues: readonly FileAccess[] = ["off", "read", "read-write"];
|
|
24
|
+
const columns = ["main", "subagents"] as const;
|
|
25
|
+
|
|
26
|
+
function snapshot(settings: PermissionSettings): PermissionSettings {
|
|
27
|
+
return { main: { ...settings.main }, subagents: { ...settings.subagents } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function errorText(error: unknown): string {
|
|
31
|
+
return error instanceof Error ? error.message : String(error);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cell(text: string, width: number): string {
|
|
35
|
+
if (width <= 0) return "";
|
|
36
|
+
const cut = truncateToWidth(text, width, "");
|
|
37
|
+
return cut + " ".repeat(Math.max(0, width - visibleWidth(cut)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A flat, keyboard-driven view; handlers own the effective policy and persistence. */
|
|
41
|
+
export function createPermissionsPage(
|
|
42
|
+
theme: Theme,
|
|
43
|
+
handlers: PermissionPageHandlers,
|
|
44
|
+
requestRender: () => void,
|
|
45
|
+
close: () => void,
|
|
46
|
+
): Component {
|
|
47
|
+
let settings: PermissionSettings | undefined;
|
|
48
|
+
let message = "";
|
|
49
|
+
let isError = false;
|
|
50
|
+
try {
|
|
51
|
+
settings = snapshot(handlers.getConfig());
|
|
52
|
+
} catch (error) {
|
|
53
|
+
message = errorText(error);
|
|
54
|
+
isError = true;
|
|
55
|
+
}
|
|
56
|
+
let row = 0;
|
|
57
|
+
let column = 0;
|
|
58
|
+
let busy = false;
|
|
59
|
+
|
|
60
|
+
function report(error: unknown): void {
|
|
61
|
+
message = errorText(error);
|
|
62
|
+
isError = true;
|
|
63
|
+
requestRender();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function change(): Promise<void> {
|
|
67
|
+
if (!settings || busy || row === rows.length) return;
|
|
68
|
+
const key = rows[row]!.key;
|
|
69
|
+
const profile = columns[column]!;
|
|
70
|
+
if (key !== "enabled" && !settings[profile].enabled) return;
|
|
71
|
+
const next = snapshot(settings);
|
|
72
|
+
if (key === "enabled" || key === "commands" || key === "network") {
|
|
73
|
+
next[profile][key] = !next[profile][key];
|
|
74
|
+
} else {
|
|
75
|
+
const current = next[profile][key];
|
|
76
|
+
next[profile][key] = fileValues[(fileValues.indexOf(current) + 1) % fileValues.length]!;
|
|
77
|
+
}
|
|
78
|
+
busy = true;
|
|
79
|
+
try {
|
|
80
|
+
await handlers.change(snapshot(next));
|
|
81
|
+
settings = next;
|
|
82
|
+
message = "";
|
|
83
|
+
isError = false;
|
|
84
|
+
requestRender();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
report(error);
|
|
87
|
+
} finally {
|
|
88
|
+
busy = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function save(): Promise<void> {
|
|
93
|
+
if (!settings || busy) return;
|
|
94
|
+
busy = true;
|
|
95
|
+
try {
|
|
96
|
+
await handlers.save(snapshot(settings));
|
|
97
|
+
message = "Defaults saved.";
|
|
98
|
+
isError = false;
|
|
99
|
+
requestRender();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
report(error);
|
|
102
|
+
} finally {
|
|
103
|
+
busy = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
invalidate() {},
|
|
109
|
+
handleInput(data: string) {
|
|
110
|
+
if (matchesKey(data, Key.escape)) {
|
|
111
|
+
close();
|
|
112
|
+
} else if (matchesKey(data, Key.up)) {
|
|
113
|
+
row = Math.max(0, row - 1);
|
|
114
|
+
requestRender();
|
|
115
|
+
} else if (matchesKey(data, Key.down)) {
|
|
116
|
+
row = Math.min(rows.length, row + 1);
|
|
117
|
+
requestRender();
|
|
118
|
+
} else if (matchesKey(data, Key.left)) {
|
|
119
|
+
column = 0;
|
|
120
|
+
requestRender();
|
|
121
|
+
} else if (matchesKey(data, Key.right)) {
|
|
122
|
+
column = 1;
|
|
123
|
+
requestRender();
|
|
124
|
+
} else if (matchesKey(data, Key.space)) {
|
|
125
|
+
void change();
|
|
126
|
+
} else if (matchesKey(data, Key.enter) && row === rows.length) {
|
|
127
|
+
void save();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
render(width: number): string[] {
|
|
131
|
+
const w = Math.max(0, Math.floor(width));
|
|
132
|
+
const prefix = w >= 20 ? 2 : 0;
|
|
133
|
+
const gap = w >= 8 ? 1 : 0;
|
|
134
|
+
const minimumCell = Math.max(1, Math.min(10, Math.floor((w - prefix - gap) / 3)));
|
|
135
|
+
const labelWidth = Math.min(28, Math.max(0, w - prefix - gap - 2 * minimumCell));
|
|
136
|
+
const available = Math.max(0, w - prefix - labelWidth - gap);
|
|
137
|
+
const mainWidth = Math.ceil(available / 2);
|
|
138
|
+
const subWidth = available - mainWidth;
|
|
139
|
+
const line = (label: string, main: string, sub: string, selected: boolean, dimMain = false, dimSub = false) => {
|
|
140
|
+
const marker = prefix ? (selected ? "> " : " ") : "";
|
|
141
|
+
const labelPart = cell(label, labelWidth);
|
|
142
|
+
const mainPart = cell(main, mainWidth);
|
|
143
|
+
const subPart = cell(sub, subWidth);
|
|
144
|
+
return theme.fg(selected ? "accent" : "text", marker + labelPart) + " ".repeat(gap) +
|
|
145
|
+
theme.fg(dimMain ? "dim" : selected && column === 0 ? "accent" : "text", mainPart) +
|
|
146
|
+
theme.fg(dimSub ? "dim" : selected && column === 1 ? "accent" : "text", subPart);
|
|
147
|
+
};
|
|
148
|
+
const output = [line("Sandbox permissions", "Main", "Subagents", false), ""];
|
|
149
|
+
for (let i = 0; i < rows.length; i++) {
|
|
150
|
+
const entry = rows[i]!;
|
|
151
|
+
const value = (profile: PermissionProfile): string => {
|
|
152
|
+
if (entry.key !== "enabled" && !profile.enabled) return "-";
|
|
153
|
+
const current = profile[entry.key];
|
|
154
|
+
return typeof current === "boolean" ? (current ? "On" : "Off") :
|
|
155
|
+
current === "read-write" ? "Read / write" : current === "read" ? "Read" : "Off";
|
|
156
|
+
};
|
|
157
|
+
output.push(line(entry.label, settings ? value(settings.main) : "-", settings ? value(settings.subagents) : "-",
|
|
158
|
+
row === i, !settings || (i > 0 && !settings.main.enabled), !settings || (i > 0 && !settings.subagents.enabled)));
|
|
159
|
+
if (i === 0) output.push("");
|
|
160
|
+
}
|
|
161
|
+
output.push("", line("Save as defaults", "", "", row === rows.length));
|
|
162
|
+
for (const hint of [
|
|
163
|
+
"↑↓ Select row · ←→ Select column · Space Change · Enter Save · Esc Back",
|
|
164
|
+
"Changes apply to new launches. Background tasks follow their launcher.",
|
|
165
|
+
"Stored credentials: known files only; excludes OS vaults and environment tokens.",
|
|
166
|
+
]) output.push(theme.fg("dim", truncateToWidth(hint, w, "")));
|
|
167
|
+
if (message) output.push(theme.fg(isError ? "error" : "muted", truncateToWidth(message.replace(/[\r\n]+/g, " "), w, "")));
|
|
168
|
+
return output;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function openPermissionsPage(ctx: ExtensionContext, handlers: PermissionPageHandlers): Promise<void> {
|
|
174
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
175
|
+
await ctx.ui.custom<null>((tui, theme, _keybindings, done) =>
|
|
176
|
+
createPermissionsPage(theme, handlers, () => tui.requestRender(), () => done(null)));
|
|
177
|
+
}
|
package/permissions.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Permission values are intent; launch backends must validate support before enforcement. */
|
|
2
|
+
export type FileAccess = "off" | "read" | "read-write";
|
|
3
|
+
export interface SandboxPermissionProfile {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
projectFiles: FileAccess;
|
|
6
|
+
outsideProject: FileAccess;
|
|
7
|
+
storedCredentials: FileAccess;
|
|
8
|
+
commands: boolean;
|
|
9
|
+
network: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SandboxPermissionSettings {
|
|
12
|
+
main: SandboxPermissionProfile;
|
|
13
|
+
subagents: SandboxPermissionProfile;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Return fresh profiles so changing one column never changes the other. */
|
|
17
|
+
export function defaultSandboxPermissions(): SandboxPermissionSettings {
|
|
18
|
+
const profile = (): SandboxPermissionProfile => ({
|
|
19
|
+
enabled: false,
|
|
20
|
+
projectFiles: "read-write",
|
|
21
|
+
outsideProject: "read",
|
|
22
|
+
storedCredentials: "read",
|
|
23
|
+
commands: true,
|
|
24
|
+
network: true,
|
|
25
|
+
});
|
|
26
|
+
return { main: profile(), subagents: { ...profile(), enabled: true } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isProfile(value: unknown): value is SandboxPermissionProfile {
|
|
30
|
+
if (!value || typeof value !== "object") return false;
|
|
31
|
+
const p = value as Record<string, unknown>;
|
|
32
|
+
const access = (v: unknown): boolean => v === "off" || v === "read" || v === "read-write";
|
|
33
|
+
return typeof p.enabled === "boolean" && typeof p.commands === "boolean" &&
|
|
34
|
+
typeof p.network === "boolean" && access(p.projectFiles) &&
|
|
35
|
+
access(p.outsideProject) && access(p.storedCredentials);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Strict decoding: malformed policy must not silently broaden permissions. */
|
|
39
|
+
export function parseSandboxPermissions(value: unknown): SandboxPermissionSettings {
|
|
40
|
+
if (!value || typeof value !== "object") throw new Error("Invalid sandbox permission settings.");
|
|
41
|
+
const settings = value as Record<string, unknown>;
|
|
42
|
+
if (!isProfile(settings.main) || !isProfile(settings.subagents)) {
|
|
43
|
+
throw new Error("Sandbox settings require Main and Subagents profiles with explicit permission values.");
|
|
44
|
+
}
|
|
45
|
+
const copy = (p: SandboxPermissionProfile): SandboxPermissionProfile => ({
|
|
46
|
+
enabled: p.enabled, projectFiles: p.projectFiles, outsideProject: p.outsideProject,
|
|
47
|
+
storedCredentials: p.storedCredentials, commands: p.commands, network: p.network,
|
|
48
|
+
});
|
|
49
|
+
return { main: copy(settings.main), subagents: copy(settings.subagents) };
|
|
50
|
+
}
|
package/shared-sandbox-core.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* OS-level write sandbox mechanism shared by Pi extensions.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* the network
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Legacy policies are write-only: a sandboxed process may READ anywhere and use
|
|
6
|
+
* the network, but may only WRITE under a canonical root plus runtime paths.
|
|
7
|
+
* Optional permissions add capability restrictions for reads, writes, launches
|
|
8
|
+
* and network access. They cover known credential files, not OS keychains,
|
|
9
|
+
* credential services, or tokens inherited in the child environment.
|
|
10
10
|
*
|
|
11
11
|
* This module owns the mechanism only: backend discovery, canonical path
|
|
12
12
|
* containment, write-deny compilation, macOS SBPL profile construction, Linux
|
|
@@ -35,6 +35,14 @@ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
|
35
35
|
/** Identifies which kernel mechanism a plan will use. */
|
|
36
36
|
export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
|
|
37
37
|
|
|
38
|
+
export type SandboxPermissions = {
|
|
39
|
+
projectFiles: "off" | "read" | "read-write";
|
|
40
|
+
outsideProject: "off" | "read" | "read-write";
|
|
41
|
+
storedCredentials: "off" | "read" | "read-write";
|
|
42
|
+
commands: boolean;
|
|
43
|
+
network: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
38
46
|
/**
|
|
39
47
|
* What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
|
|
40
48
|
* be relative or contain symlinks; they are canonicalized before use.
|
|
@@ -55,6 +63,10 @@ export type SandboxWritePolicy = {
|
|
|
55
63
|
denyWrite?: readonly string[];
|
|
56
64
|
/** Home directory whose `~/.pi` state stays writable on macOS. */
|
|
57
65
|
home: string;
|
|
66
|
+
/** Optional capability profile; omission preserves the original write-only sandbox. */
|
|
67
|
+
permissions?: SandboxPermissions;
|
|
68
|
+
/** Trusted per-launch runtime state, never supplied by model tool arguments. */
|
|
69
|
+
runtimeWrite?: readonly string[];
|
|
58
70
|
};
|
|
59
71
|
|
|
60
72
|
/** The executable and argv to run inside the sandbox, preserved verbatim. */
|
|
@@ -109,6 +121,9 @@ export type CompiledSandboxWritePolicy = {
|
|
|
109
121
|
readonly writableRoot: string;
|
|
110
122
|
readonly denyWrite: readonly string[];
|
|
111
123
|
readonly home: string;
|
|
124
|
+
readonly permissions?: SandboxPermissions;
|
|
125
|
+
readonly credentialPaths?: readonly string[];
|
|
126
|
+
readonly runtimeWrite?: readonly string[];
|
|
112
127
|
};
|
|
113
128
|
|
|
114
129
|
/** Why a write target is or is not permitted by a compiled policy. */
|
|
@@ -117,11 +132,15 @@ export type WriteAccessDecision =
|
|
|
117
132
|
| {
|
|
118
133
|
allowed: false;
|
|
119
134
|
path: string;
|
|
120
|
-
reason: "outside-writable-root" | "write-denied";
|
|
135
|
+
reason: "outside-writable-root" | "write-denied" | "permission-denied";
|
|
121
136
|
/** The compiled deny entry that matched, for `write-denied` only. */
|
|
122
137
|
deniedBy?: string;
|
|
123
138
|
};
|
|
124
139
|
|
|
140
|
+
export type ReadAccessDecision =
|
|
141
|
+
| { allowed: true; path: string }
|
|
142
|
+
| { allowed: false; path: string; reason: "read-denied" };
|
|
143
|
+
|
|
125
144
|
/** What the current platform can enforce, and why it cannot when it cannot. */
|
|
126
145
|
export type SandboxSupport =
|
|
127
146
|
| { supported: true; platform: string; backend: SandboxBackendId; executable: string }
|
|
@@ -141,6 +160,29 @@ type SandboxBackend = {
|
|
|
141
160
|
|
|
142
161
|
const MACOS_SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
143
162
|
|
|
163
|
+
const CREDENTIAL_LOCATIONS = [
|
|
164
|
+
".ssh", ".aws", ".config/gh", ".config/gcloud", ".azure", ".kube",
|
|
165
|
+
".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
|
|
166
|
+
] as const;
|
|
167
|
+
|
|
168
|
+
// Executables, dynamic libraries and OS frameworks needed to start a child.
|
|
169
|
+
// This deliberately excludes the home directory, /etc, and credential stores.
|
|
170
|
+
// A process relying on /etc or /proc configuration (DNS, certificates, NSS)
|
|
171
|
+
// may not start or function under Linux outsideProject=off; callers must not
|
|
172
|
+
// silently remount these broad host trees to work around that failure.
|
|
173
|
+
const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Library", "/System/Cryptexes", "/Library/Apple", "/Library/Developer", "/opt/homebrew"];
|
|
174
|
+
const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
|
|
175
|
+
const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
|
|
176
|
+
|
|
177
|
+
/** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
|
|
178
|
+
export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
|
|
179
|
+
const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
|
|
180
|
+
const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
181
|
+
if (configuredAgentDir) paths.push(join(configuredAgentDir.startsWith("~/")
|
|
182
|
+
? join(home, configuredAgentDir.slice(2)) : configuredAgentDir, "auth.json"));
|
|
183
|
+
return [...new Set(paths.map((path) => canonicalizePath(path, seams)))].sort();
|
|
184
|
+
}
|
|
185
|
+
|
|
144
186
|
function currentPlatform(seams: SandboxSeams): string {
|
|
145
187
|
return (seams.platform ?? osPlatform)();
|
|
146
188
|
}
|
|
@@ -154,7 +196,19 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
|
|
|
154
196
|
const canonicalize = seams.canonicalize ?? realpathSync;
|
|
155
197
|
const absolute = resolve(path);
|
|
156
198
|
try {
|
|
157
|
-
|
|
199
|
+
const resolved = canonicalize(absolute);
|
|
200
|
+
// APFS firmlinks are not resolved by realpath. Normalize the Data-volume
|
|
201
|
+
// alias only when both names demonstrably refer to the same inode.
|
|
202
|
+
const dataPrefix = "/System/Volumes/Data";
|
|
203
|
+
if (!seams.canonicalize && currentPlatform(seams) === "darwin" && resolved.startsWith(`${dataPrefix}/`)) {
|
|
204
|
+
const candidate = resolved.slice(dataPrefix.length);
|
|
205
|
+
try {
|
|
206
|
+
const source = statSync(resolved);
|
|
207
|
+
const alias = statSync(candidate);
|
|
208
|
+
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
|
|
209
|
+
} catch { /* An unrelated Data-volume path keeps its original identity. */ }
|
|
210
|
+
}
|
|
211
|
+
return resolved;
|
|
158
212
|
} catch {
|
|
159
213
|
// Not created yet (or unreadable): canonicalize the parent instead.
|
|
160
214
|
}
|
|
@@ -179,7 +233,14 @@ function compile(
|
|
|
179
233
|
...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
|
|
180
234
|
].sort();
|
|
181
235
|
|
|
182
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
writableRoot, denyWrite, home: policy.home,
|
|
238
|
+
...(policy.permissions && {
|
|
239
|
+
permissions: { ...policy.permissions },
|
|
240
|
+
credentialPaths: credentialFilePaths(policy.home, seams),
|
|
241
|
+
runtimeWrite: (policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
|
|
242
|
+
}),
|
|
243
|
+
};
|
|
183
244
|
}
|
|
184
245
|
|
|
185
246
|
/**
|
|
@@ -198,6 +259,31 @@ function contains(root: string, target: string): boolean {
|
|
|
198
259
|
return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
199
260
|
}
|
|
200
261
|
|
|
262
|
+
function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
|
|
263
|
+
return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function runtimeRoots(seams: SandboxSeams): string[] {
|
|
267
|
+
return [...new Set(READ_RUNTIME_ROOTS.map((path) => canonicalizePath(path, seams)))];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Decide read access using the same canonical path and capability precedence as writes. */
|
|
271
|
+
export function evaluateReadAccess(
|
|
272
|
+
target: string,
|
|
273
|
+
policy: CompiledSandboxWritePolicy,
|
|
274
|
+
seams: SandboxSeams = {},
|
|
275
|
+
): ReadAccessDecision {
|
|
276
|
+
const path = canonicalizePath(target, seams);
|
|
277
|
+
const permissions = policy.permissions;
|
|
278
|
+
if (!permissions) return { allowed: true, path };
|
|
279
|
+
const mode = isCredential(path, policy) ? permissions.storedCredentials
|
|
280
|
+
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
281
|
+
: contains(policy.writableRoot, path) ? permissions.projectFiles
|
|
282
|
+
: path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
|
|
283
|
+
: permissions.outsideProject;
|
|
284
|
+
return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
285
|
+
}
|
|
286
|
+
|
|
201
287
|
/**
|
|
202
288
|
* Decide whether an in-process write to `target` is permitted by a compiled
|
|
203
289
|
* policy. This is the same containment rule the kernel backends enforce, for
|
|
@@ -209,7 +295,7 @@ export function evaluateWriteAccess(
|
|
|
209
295
|
seams: SandboxSeams = {},
|
|
210
296
|
): WriteAccessDecision {
|
|
211
297
|
const path = canonicalizePath(target, seams);
|
|
212
|
-
if (!contains(policy.writableRoot, path)) {
|
|
298
|
+
if (!policy.permissions && !contains(policy.writableRoot, path)) {
|
|
213
299
|
return { allowed: false, path, reason: "outside-writable-root" };
|
|
214
300
|
}
|
|
215
301
|
for (const denied of policy.denyWrite) {
|
|
@@ -217,6 +303,14 @@ export function evaluateWriteAccess(
|
|
|
217
303
|
return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
218
304
|
}
|
|
219
305
|
}
|
|
306
|
+
if (policy.permissions) {
|
|
307
|
+
const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
|
|
308
|
+
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
309
|
+
: contains(policy.writableRoot, path) ? policy.permissions.projectFiles
|
|
310
|
+
: contains(canonicalizePath("/dev", seams), path) ? "read-write"
|
|
311
|
+
: policy.permissions.outsideProject;
|
|
312
|
+
if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
|
|
313
|
+
}
|
|
220
314
|
return { allowed: true, path };
|
|
221
315
|
}
|
|
222
316
|
|
|
@@ -225,13 +319,70 @@ function sbpl(path: string): string {
|
|
|
225
319
|
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
226
320
|
}
|
|
227
321
|
|
|
322
|
+
function validateRuntimeHome(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): void {
|
|
323
|
+
if (policy.permissions?.outsideProject !== "off") return;
|
|
324
|
+
const home = canonicalizePath(policy.home, seams);
|
|
325
|
+
if (RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), home))) {
|
|
326
|
+
throw new Error("outsideProject=off cannot expose a home under a required system runtime root; move the home or use another permission mode.");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function protectedAncestors(paths: readonly string[]): string[] {
|
|
331
|
+
const parents = new Set<string>();
|
|
332
|
+
for (const path of paths) {
|
|
333
|
+
for (let parent = dirname(path); dirname(parent) !== parent; parent = dirname(parent)) parents.add(parent);
|
|
334
|
+
}
|
|
335
|
+
return [...parents].sort((a, b) => a.length - b.length);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
|
|
339
|
+
validateRuntimeHome(policy, seams);
|
|
340
|
+
const permissions = policy.permissions!;
|
|
341
|
+
const protectedPaths = [
|
|
342
|
+
...policy.denyWrite,
|
|
343
|
+
...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
|
|
344
|
+
...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
|
|
345
|
+
];
|
|
346
|
+
const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
347
|
+
if (permissions.outsideProject === "off") {
|
|
348
|
+
rules.push("(deny file-read*)", "(allow file-read-metadata)", '(allow file-read* (literal "/"))');
|
|
349
|
+
for (const root of runtimeRoots(seams)) {
|
|
350
|
+
rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (permissions.outsideProject === "read-write") rules.push("(allow file-write*)");
|
|
354
|
+
// Temporary host paths are not granted for off/read: doing so would expose
|
|
355
|
+
// other users' files in temp. /dev remains necessary for basic shell I/O.
|
|
356
|
+
for (const root of TEMP_ROOTS.filter((path) => permissions.outsideProject === "read-write" || path === "/dev")
|
|
357
|
+
.map((path) => canonicalizePath(path, seams))) {
|
|
358
|
+
rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
359
|
+
}
|
|
360
|
+
const scoped = (root: string, mode: SandboxPermissions["projectFiles"]) => {
|
|
361
|
+
if (mode === "off") rules.push(`(deny file-read* (subpath ${sbpl(root)}))`);
|
|
362
|
+
else rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
363
|
+
if (mode === "read-write") rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
364
|
+
else rules.push(`(deny file-write* (subpath ${sbpl(root)}))`);
|
|
365
|
+
};
|
|
366
|
+
// Last matching SBPL rule wins. Credential rules override project and outside;
|
|
367
|
+
// explicit denyWrite entries always override every write allowance.
|
|
368
|
+
scoped(policy.writableRoot, permissions.projectFiles);
|
|
369
|
+
for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
|
|
370
|
+
for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
|
|
371
|
+
for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
|
|
372
|
+
// Protect the directory entries, not their contents: unrelated children can
|
|
373
|
+
// still be created, while renaming a parent cannot move a denied subtree.
|
|
374
|
+
for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
375
|
+
if (!permissions.network) rules.push("(deny network*)");
|
|
376
|
+
return [...rules, ""].join("\n");
|
|
377
|
+
}
|
|
378
|
+
|
|
228
379
|
/** Build the macOS sandbox-exec wrapper and its SBPL profile. */
|
|
229
380
|
function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
|
|
230
381
|
// Match on the real (symlink-resolved) path — sandbox-exec evaluates the
|
|
231
382
|
// canonical path, so /tmp/x must be written as /private/tmp/x.
|
|
232
383
|
const policy = compile(args.policy, seams, false);
|
|
233
384
|
|
|
234
|
-
const profile = [
|
|
385
|
+
const profile = policy.permissions ? buildPermissionProfile(policy, seams) : [
|
|
235
386
|
"(version 1)",
|
|
236
387
|
"(allow default)", // permissive base: reads, exec, network
|
|
237
388
|
"(deny file-write*)", // ...then deny all writes...
|
|
@@ -344,6 +495,7 @@ function buildLinuxSandboxCommand(
|
|
|
344
495
|
// boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
|
|
345
496
|
// widening the writable root.
|
|
346
497
|
const policy = compile(args.policy, seams, true);
|
|
498
|
+
if (policy.permissions) return buildLinuxPermissionCommand(bwrap, args, policy, seams);
|
|
347
499
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
348
500
|
const denyBinds = policy.denyWrite.flatMap((path) => {
|
|
349
501
|
const mountable = writableInsideLinuxSandbox(path, policy.writableRoot) && materialize(path);
|
|
@@ -368,6 +520,113 @@ function buildLinuxSandboxCommand(
|
|
|
368
520
|
};
|
|
369
521
|
}
|
|
370
522
|
|
|
523
|
+
function buildLinuxPermissionCommand(
|
|
524
|
+
bwrap: string,
|
|
525
|
+
args: SandboxCommandArgs,
|
|
526
|
+
policy: CompiledSandboxWritePolicy,
|
|
527
|
+
seams: SandboxSeams,
|
|
528
|
+
): SandboxCommand {
|
|
529
|
+
const permissions = policy.permissions!;
|
|
530
|
+
validateRuntimeHome(policy, seams);
|
|
531
|
+
const project = policy.writableRoot;
|
|
532
|
+
const credentials = policy.credentialPaths ?? [];
|
|
533
|
+
const writableProject = permissions.projectFiles === "read-write";
|
|
534
|
+
const overlappingCredentials = credentials.filter((path) => contains(project, path));
|
|
535
|
+
if (credentials.some((path) => contains(path, project)) &&
|
|
536
|
+
permissions.projectFiles !== permissions.storedCredentials) {
|
|
537
|
+
throw new Error("Linux bubblewrap cannot apply differing project and credential permissions when a credential directory contains the project.");
|
|
538
|
+
}
|
|
539
|
+
if (writableProject && policy.denyWrite.some((path) => contains(path, project))) {
|
|
540
|
+
throw new Error("Linux bubblewrap cannot make a project writable inside a write-denied directory.");
|
|
541
|
+
}
|
|
542
|
+
// Protected leaves and their writable ancestors become mount points below.
|
|
543
|
+
// Linux refuses renaming mount points, preventing ancestor replacement.
|
|
544
|
+
if (permissions.outsideProject === "read-write" && (
|
|
545
|
+
permissions.projectFiles !== "read-write" || permissions.storedCredentials !== "read-write" ||
|
|
546
|
+
policy.denyWrite.length > 0
|
|
547
|
+
)) {
|
|
548
|
+
throw new Error("Linux bubblewrap cannot enforce restricted project/credential/denyWrite paths under a writable outsideProject mount.");
|
|
549
|
+
}
|
|
550
|
+
if (permissions.outsideProject === "read" && permissions.storedCredentials === "read-write" &&
|
|
551
|
+
credentials.some((path) => !contains(project, path))) {
|
|
552
|
+
throw new Error("Linux bubblewrap cannot write credential stores under a read-only outsideProject root.");
|
|
553
|
+
}
|
|
554
|
+
if (permissions.projectFiles === "read" && permissions.storedCredentials === "read-write" &&
|
|
555
|
+
overlappingCredentials.length > 0) {
|
|
556
|
+
throw new Error("Linux bubblewrap cannot write credential stores under a read-only project mount.");
|
|
557
|
+
}
|
|
558
|
+
if (permissions.outsideProject === "read" && permissions.storedCredentials === "off") {
|
|
559
|
+
throw new Error("Linux bubblewrap cannot hide stored credentials in a read-only whole-root bind.");
|
|
560
|
+
}
|
|
561
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "off" &&
|
|
562
|
+
credentials.some((path) => contains(project, path) && permissions.projectFiles !== "off")) {
|
|
563
|
+
throw new Error("Linux bubblewrap cannot hide credentials inside a visible read-only project.");
|
|
564
|
+
}
|
|
565
|
+
if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
|
|
566
|
+
RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), project))) {
|
|
567
|
+
throw new Error("Linux bubblewrap cannot hide a project nested under a required system runtime bind.");
|
|
568
|
+
}
|
|
569
|
+
if (permissions.outsideProject === "off" && credentials.some((path) =>
|
|
570
|
+
!contains(project, path) &&
|
|
571
|
+
RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), path)) &&
|
|
572
|
+
permissions.storedCredentials === "off")) {
|
|
573
|
+
throw new Error("Linux bubblewrap cannot hide credentials under a required system runtime bind.");
|
|
574
|
+
}
|
|
575
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read-write" &&
|
|
576
|
+
credentials.some((path) => !contains(project, path))) {
|
|
577
|
+
throw new Error("Linux bubblewrap cannot create or safely bind writable credential stores outside a hidden root.");
|
|
578
|
+
}
|
|
579
|
+
if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
|
|
580
|
+
permissions.storedCredentials !== "off" && credentials.some((path) => contains(project, path))) {
|
|
581
|
+
throw new Error("Linux bubblewrap cannot expose credentials inside a hidden project without exposing the project.");
|
|
582
|
+
}
|
|
583
|
+
if (permissions.outsideProject !== "off" && permissions.projectFiles === "off") {
|
|
584
|
+
throw new Error("Linux bubblewrap cannot hide a project inside a visible outsideProject root.");
|
|
585
|
+
}
|
|
586
|
+
const mounts: string[] = permissions.outsideProject === "off" ? ["--tmpfs", "/"]
|
|
587
|
+
: [permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/", "/"];
|
|
588
|
+
if (permissions.outsideProject === "off") {
|
|
589
|
+
// Bounded system executable/library roots only. /tmp is private, not a
|
|
590
|
+
// host bind: otherwise outsideProject=off would expose user temp data.
|
|
591
|
+
for (const root of RUNTIME_ROOTS) {
|
|
592
|
+
if (existsSync(root)) mounts.push("--ro-bind", root, root);
|
|
593
|
+
}
|
|
594
|
+
mounts.push("--tmpfs", "/tmp");
|
|
595
|
+
} else {
|
|
596
|
+
mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
597
|
+
}
|
|
598
|
+
mounts.push("--dev", "/dev");
|
|
599
|
+
if (permissions.projectFiles !== "off") {
|
|
600
|
+
mounts.push(writableProject ? "--bind" : "--ro-bind", project, project);
|
|
601
|
+
}
|
|
602
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
|
|
603
|
+
for (const path of credentials) {
|
|
604
|
+
if (existsSync(path) && !contains(project, path)) mounts.push("--ro-bind", path, path);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const path of policy.runtimeWrite ?? []) {
|
|
608
|
+
if ([...credentials, ...policy.denyWrite].some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path))) {
|
|
609
|
+
throw new Error("Runtime directory overlaps protected credentials or control paths.");
|
|
610
|
+
}
|
|
611
|
+
mounts.push("--bind", path, path);
|
|
612
|
+
}
|
|
613
|
+
const protectedPaths = [...policy.denyWrite,
|
|
614
|
+
...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
|
|
615
|
+
if (writableProject) {
|
|
616
|
+
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
617
|
+
const leaves = protectedPaths.filter((path) => contains(project, path) && materialize(path));
|
|
618
|
+
for (const parent of protectedAncestors(leaves).filter((path) => contains(project, path) && path !== project)) {
|
|
619
|
+
mounts.push("--bind", parent, parent);
|
|
620
|
+
}
|
|
621
|
+
for (const path of leaves) mounts.push("--ro-bind", path, path);
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
file: bwrap,
|
|
625
|
+
fileArgs: [...mounts, ...(!permissions.network ? ["--unshare-net"] : []),
|
|
626
|
+
"--", args.execPath, ...args.execArgs],
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
371
630
|
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
372
631
|
const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
|
|
373
632
|
if (!bwrap) return undefined;
|
|
@@ -436,10 +695,16 @@ export function maybeBuildSandboxCommand(
|
|
|
436
695
|
request: SandboxRequest,
|
|
437
696
|
seams: SandboxSeams = {},
|
|
438
697
|
): SandboxCommand | undefined {
|
|
698
|
+
if (args.policy.permissions?.commands === false) {
|
|
699
|
+
throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
|
|
700
|
+
}
|
|
439
701
|
if (!request.sandboxEnabled) return undefined;
|
|
440
702
|
|
|
441
703
|
const backend = selectedSandboxBackend(seams);
|
|
442
704
|
if (!backend) {
|
|
705
|
+
if (args.policy.permissions) {
|
|
706
|
+
throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
|
|
707
|
+
}
|
|
443
708
|
if (request.explicitSandbox) {
|
|
444
709
|
const reason = sandboxUnavailableMessage(seams);
|
|
445
710
|
throw new Error(request.remedy ? `${reason} ${request.remedy}` : reason);
|
|
@@ -458,5 +723,12 @@ export function buildSandboxCommand(
|
|
|
458
723
|
args: SandboxCommandArgs,
|
|
459
724
|
seams: SandboxSeams = {},
|
|
460
725
|
): SandboxCommand {
|
|
461
|
-
|
|
726
|
+
if (args.policy.permissions?.commands === false) {
|
|
727
|
+
throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
|
|
728
|
+
}
|
|
729
|
+
const backend = selectedSandboxBackend(seams);
|
|
730
|
+
if (!backend && args.policy.permissions) {
|
|
731
|
+
throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
|
|
732
|
+
}
|
|
733
|
+
return (backend ?? macOSSandboxBackend).buildCommand(args, seams);
|
|
462
734
|
}
|
package/state.ts
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
* default and clears the previous override.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
15
|
import { createHash } from "node:crypto";
|
|
15
16
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
17
|
import { homedir, tmpdir } from "node:os";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
|
|
20
|
+
import { parseSandboxPermissions, type SandboxPermissionSettings, type SandboxPermissionProfile } from "./permissions.ts";
|
|
19
21
|
import {
|
|
20
22
|
canonicalizePath,
|
|
21
23
|
describeSandboxSupport,
|
|
@@ -58,9 +60,11 @@ export type ForegroundSandboxStatus = {
|
|
|
58
60
|
readonly backend: SandboxBackendId | undefined;
|
|
59
61
|
readonly executable: string | undefined;
|
|
60
62
|
/** Reads are never restricted by this sandbox. */
|
|
61
|
-
readonly readPolicy: "unrestricted";
|
|
62
|
-
/** Network
|
|
63
|
-
readonly networkPolicy: "unrestricted";
|
|
63
|
+
readonly readPolicy: "unrestricted" | "restricted";
|
|
64
|
+
/** Network policy for confined commands (not the parent Pi provider connection). */
|
|
65
|
+
readonly networkPolicy: "unrestricted" | "blocked";
|
|
66
|
+
readonly permissions?: Readonly<SandboxPermissionProfile>;
|
|
67
|
+
readonly subagentPermissions?: Readonly<SandboxPermissionProfile>;
|
|
64
68
|
/** Human-readable evidence for why `state` is what it is. */
|
|
65
69
|
readonly reason: string;
|
|
66
70
|
};
|
|
@@ -113,6 +117,8 @@ export class ForegroundSandboxController {
|
|
|
113
117
|
#defaultEnabled = false;
|
|
114
118
|
#sessionOverride: boolean | undefined;
|
|
115
119
|
#profileDir: string | undefined;
|
|
120
|
+
#permissions: SandboxPermissionSettings | undefined;
|
|
121
|
+
#policyProblem: string | undefined;
|
|
116
122
|
|
|
117
123
|
constructor(seams: ForegroundSandboxSeams = {}) {
|
|
118
124
|
this.#seams = seams;
|
|
@@ -133,6 +139,8 @@ export class ForegroundSandboxController {
|
|
|
133
139
|
);
|
|
134
140
|
this.#defaultEnabled = defaultEnabled;
|
|
135
141
|
this.#sessionOverride = undefined;
|
|
142
|
+
this.#permissions = undefined;
|
|
143
|
+
this.#policyProblem = undefined;
|
|
136
144
|
return this.status();
|
|
137
145
|
}
|
|
138
146
|
|
|
@@ -145,6 +153,7 @@ export class ForegroundSandboxController {
|
|
|
145
153
|
/** Turn protection off for this session only. Never persisted. */
|
|
146
154
|
disable(): ForegroundSandboxStatus {
|
|
147
155
|
this.#sessionOverride = false;
|
|
156
|
+
this.#policyProblem = undefined;
|
|
148
157
|
return this.status();
|
|
149
158
|
}
|
|
150
159
|
|
|
@@ -160,6 +169,27 @@ export class ForegroundSandboxController {
|
|
|
160
169
|
return this.#sessionOverride ?? this.#defaultEnabled;
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
/** Apply human-selected profiles. Disabled profiles keep their detail values. */
|
|
173
|
+
setPermissionSettings(settings: SandboxPermissionSettings): ForegroundSandboxStatus {
|
|
174
|
+
this.#permissions = parseSandboxPermissions(settings);
|
|
175
|
+
this.#policyProblem = undefined;
|
|
176
|
+
this.#sessionOverride = settings.main.enabled;
|
|
177
|
+
return this.status();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
block(reason: string): ForegroundSandboxStatus {
|
|
181
|
+
this.#policyProblem = reason;
|
|
182
|
+
return this.status();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
permissionSettings(): SandboxPermissionSettings | undefined {
|
|
186
|
+
if (!this.#permissions) return undefined;
|
|
187
|
+
return parseSandboxPermissions({
|
|
188
|
+
...this.#permissions,
|
|
189
|
+
main: { ...this.#permissions.main, enabled: this.isUserEnabled() },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
163
193
|
/** The deny-write templates currently in force (packaged defaults for now). */
|
|
164
194
|
denyWriteTemplates(): readonly string[] {
|
|
165
195
|
return this.#denyTemplates;
|
|
@@ -185,12 +215,25 @@ export class ForegroundSandboxController {
|
|
|
185
215
|
const support = describeSandboxSupport(this.#seams);
|
|
186
216
|
const base = {
|
|
187
217
|
projectRoot: this.#projectRoot,
|
|
188
|
-
denyWrite: this.#
|
|
218
|
+
denyWrite: this.#permissions
|
|
219
|
+
? Object.freeze([...this.#denyWrite, join(getAgentDir(), "extensions")])
|
|
220
|
+
: this.#denyWrite,
|
|
189
221
|
platform: support.platform,
|
|
190
|
-
readPolicy: "unrestricted",
|
|
191
|
-
networkPolicy: "unrestricted",
|
|
222
|
+
readPolicy: this.isUserEnabled() && this.#permissions && (this.#permissions.main.projectFiles === "off" || this.#permissions.main.outsideProject === "off" || this.#permissions.main.storedCredentials === "off") ? "restricted" as const : "unrestricted" as const,
|
|
223
|
+
networkPolicy: this.isUserEnabled() && this.#permissions?.main.network === false ? "blocked" as const : "unrestricted" as const,
|
|
224
|
+
...(this.#permissions ? {
|
|
225
|
+
permissions: Object.freeze({ ...this.#permissions.main, enabled: this.isUserEnabled() }),
|
|
226
|
+
subagentPermissions: Object.freeze({ ...this.#permissions.subagents }),
|
|
227
|
+
} : {}),
|
|
192
228
|
} as const;
|
|
193
229
|
|
|
230
|
+
if (this.#policyProblem !== undefined) {
|
|
231
|
+
return Object.freeze({ ...base, state: "failed", writableRoot: undefined,
|
|
232
|
+
backend: support.supported ? support.backend : undefined,
|
|
233
|
+
executable: support.supported ? support.executable : undefined,
|
|
234
|
+
reason: this.#policyProblem });
|
|
235
|
+
}
|
|
236
|
+
|
|
194
237
|
if (this.#projectRoot === undefined) {
|
|
195
238
|
return Object.freeze({
|
|
196
239
|
...base,
|
|
@@ -246,7 +289,9 @@ export class ForegroundSandboxController {
|
|
|
246
289
|
writableRoot: this.#projectRoot,
|
|
247
290
|
backend: support.backend,
|
|
248
291
|
executable: support.executable,
|
|
249
|
-
reason:
|
|
292
|
+
reason: this.#permissions
|
|
293
|
+
? `Sandbox permissions are enforced by ${support.backend} (${support.executable}) for ${this.#projectRoot}.`
|
|
294
|
+
: `Writes are confined to ${this.#projectRoot} by ${support.backend} (${support.executable}).`,
|
|
250
295
|
});
|
|
251
296
|
}
|
|
252
297
|
|
|
@@ -274,6 +319,7 @@ export class ForegroundSandboxController {
|
|
|
274
319
|
writableRoot: status.writableRoot,
|
|
275
320
|
denyWrite: Object.freeze([...status.denyWrite, profileDir]),
|
|
276
321
|
home: (this.#seams.home ?? homedir)(),
|
|
322
|
+
...(status.permissions ? { permissions: status.permissions } : {}),
|
|
277
323
|
};
|
|
278
324
|
return { confined: true, policy, profilePath: this.#profilePathFor(policy) };
|
|
279
325
|
}
|
|
@@ -296,7 +342,7 @@ export class ForegroundSandboxController {
|
|
|
296
342
|
*/
|
|
297
343
|
#profilePathFor(policy: SandboxWritePolicy): string {
|
|
298
344
|
const digest = createHash("sha256")
|
|
299
|
-
.update(JSON.stringify(
|
|
345
|
+
.update(JSON.stringify(policy))
|
|
300
346
|
.digest("hex")
|
|
301
347
|
.slice(0, 16);
|
|
302
348
|
return join(this.#profileDirectory(), `foreground-${digest}.sb`);
|
package/status.ts
CHANGED
|
@@ -57,7 +57,7 @@ export function formatSandboxReport(status: ForegroundSandboxStatus): string {
|
|
|
57
57
|
"",
|
|
58
58
|
`Project root: ${status.projectRoot ?? "(not captured yet)"}`,
|
|
59
59
|
`Writable root: ${status.writableRoot ?? "(none while not enabled)"}`,
|
|
60
|
-
`Reads: ${status.readPolicy} (every filesystem path)`,
|
|
60
|
+
`Reads: ${status.readPolicy}${status.readPolicy === "unrestricted" ? " (every filesystem path)" : " (selected file permissions)"}`,
|
|
61
61
|
`Network: ${status.networkPolicy}`,
|
|
62
62
|
`Platform: ${status.platform}`,
|
|
63
63
|
`Backend: ${status.backend ?? "(none resolved)"}`,
|