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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-better-sandbox
2
2
 
3
- An opt-in write sandbox for Pi's foreground tools.
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. The sandbox starts inactive. Use `/sandbox on`
13
- for the current session or `/sandbox default on` to persist opt-in. While
14
- enabled, Pi's built-in `bash` tool and the `!` / `!!` commands you type yourself
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
- Read: every filesystem path
21
- Write: the canonical launch directory and everything under it
22
- Exceptions: .git/hooks, .env, .env.local
23
- Network: unchanged
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
- **Reads and network access are never restricted.** Every path on the filesystem
40
- stays readable and network behaviour is exactly what it was. This sandbox limits
41
- writes, and nothing else.
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
- Writes are confined for the integrated first-party execution paths, and only
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` and `edit` tools.
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 show the effective status
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. `/sandbox default on|off` stores
168
- the default for those future sessions in
169
- `~/.pi/agent/extensions/pi-better-sandbox-preferences.json`.
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
- "Show the foreground write sandbox, change session or persistent activation, or manage write-denied paths";
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) => ({ value, label: 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 stay unrestricted. `edit`'s `readFile` is delegated untouched; only the
20
- * operations that can change the host filesystem are guarded.
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
- return decision.reason === "outside-writable-root"
81
- ? `${refused} Writes are confined to ${policy.writableRoot}.`
82
- : `${refused} ${decision.deniedBy} is a write-denied path.`;
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([policy.writableRoot, policy.denyWrite ?? [], policy.home]);
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` is delegated untouched — this
193
- * sandbox never restricts reads.
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. The built-in `write`
9
- * and `edit` tools mutate files in Pi's own process rather than in a child, so
10
- * no argv wrapping can reach them; they are held to the same policy by an
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 { readSandboxDefault, writeSandboxDefault } from "./preferences.ts";
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
- // Every session re-reads the persistent activation preference. Missing
114
- // or malformed state resolves to the product default (off), never to an
115
- // unexpected fail-closed session.
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
- defaultEnabled = readSandboxDefault() === "on";
157
+ settings = readPermissionSettings();
119
158
  } catch (error) {
120
- ctx.ui.notify(
121
- `Foreground sandbox preference ignored; defaulting off: ${error instanceof Error ? error.message : String(error)}`,
122
- "warning",
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, defaultEnabled);
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.applyDefault(enabled);
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.2.0",
4
- "description": "Pi extension that confines foreground shell execution to the project directory with a kernel-enforced write sandbox.",
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
+ }
@@ -2,11 +2,11 @@
2
2
  /**
3
3
  * OS-level write sandbox mechanism shared by Pi extensions.
4
4
  *
5
- * Kernel-enforced confinement: the sandboxed process may READ anywhere and use
6
- * the network (so web_fetch and the model API keep working), but may only WRITE
7
- * under a single canonical root plus the system paths pi itself needs. Unlike a
8
- * cooperative guardrails layer (which pattern-matches tool inputs), this cannot
9
- * be evaded by a crafted bash command — the write syscall itself is denied.
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
- return canonicalize(absolute);
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 { writableRoot, denyWrite, home: policy.home };
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
- return (selectedSandboxBackend(seams) ?? macOSSandboxBackend).buildCommand(args, seams);
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 is never restricted by this sandbox. */
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.#denyWrite,
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: `Writes are confined to ${this.#projectRoot} by ${support.backend} (${support.executable}).`,
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([policy.writableRoot, policy.denyWrite, policy.home]))
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)"}`,