pi-better-sandbox 0.3.0 → 0.5.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,59 +9,79 @@ 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
 
19
- ```
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
- ```
25
-
26
- For shell commands the denial is done by the kernel, not by inspecting command
27
- text: macOS uses Seatbelt (`sandbox-exec`) and Linux uses Bubblewrap (`bwrap`).
28
- A crafted command cannot talk its way past it, because the write syscall itself
29
- is refused.
30
-
31
- `write` and `edit` never start a child process — they change files inside Pi's
32
- own process — so there is no child to wrap. They are confined by a containment
33
- check on the canonical target instead, run inside Pi's own file-mutation queue,
34
- immediately before the filesystem call it guards. A refused mutation leaves
35
- nothing behind on disk.
36
-
37
- ## What is confined, and what is not
16
+ ```text
17
+ Sandbox permissions Main Subagents
38
18
 
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.
19
+ Sandbox Off On
42
20
 
43
- Writes are confined for the integrated first-party execution paths, and only
44
- those:
21
+ Project files - Read / write
22
+ Outside project - Read
23
+ Stored credentials - Read
24
+ Run commands & applications - On
25
+ Network access - On
45
26
 
46
- - Pi's built-in `bash` tool.
47
- - User-entered `!` and `!!` commands.
48
- - Pi's built-in `write` and `edit` tools.
49
- - Local [`pi-better-background-tasks`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-background-tasks#readme)
50
- spawns and watches, which capture this policy at launch.
51
- - [`pi-better-subagents`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-subagents#readme)
52
- children, through the same shared mechanism.
27
+ ↑↓ Select row ←→ Select column Space Change
28
+ Save as defaults
29
+ ```
53
30
 
54
- **Not** confined:
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.
43
+
44
+ File and shell operations use the kernel: macOS uses Seatbelt (`sandbox-exec`)
45
+ and Linux uses Bubblewrap (`bwrap`). `read`, `write`, and `edit` keep Pi's normal
46
+ tool behavior and mutation queues, while a fixed worker performs filesystem
47
+ syscalls under the selected policy. Canonical checks explain denials; kernel
48
+ enforcement also protects against a symlink changing between checking and use.
49
+ The confined file worker rejects files over 8 MiB instead of silently truncating
50
+ them; larger-file processing can use a confined command when commands are On.
55
51
 
56
- - Pi's own process.
57
- - `pi.exec` calls made by extensions.
58
- - Unrelated third-party extension code.
59
- - Another first-party surface's control plane. Each surface denies its own —
60
- the files naming what it will run next — but not every other surface's, so
61
- confinement is per surface rather than global.
52
+ ## What is confined, and what is not
62
53
 
63
- This is a tool-execution sandbox. It limits accidental damage from commands the
64
- model or you run through Pi's shell; it is not a boundary around Pi itself.
54
+ Pi is the trusted runtime. It can lock configuration/authentication files,
55
+ connect to its provider, and persist sessions. Main and Subagents permissions
56
+ apply to task operations. Subagents can start with task Network access or Run
57
+ commands & applications Off. The fixed file worker remains available according
58
+ to the file permissions even when task commands are Off.
59
+
60
+ The task executor provides a private scratch directory through `TMPDIR`, `TMP`,
61
+ and `TEMP`. Only that directory is writable in addition to the selected project
62
+ and explicit file grants; a protected anchor prevents replacing its root with a
63
+ symlink. It is separate from Pi's runtime control files.
64
+
65
+ The currently admitted model-tool implementations are `read`, `write`, `edit`,
66
+ and `bash`. User-entered `!` and `!!` commands use the same shell policy. Other
67
+ model-callable tools require a verified execution adapter and are refused while
68
+ that actor's sandbox is enabled; enabling network alone does not admit them.
69
+ Subagent launch output identifies requested tools that are unavailable. Main
70
+ remains Off by default, so its ordinary orchestration tools remain available
71
+ unless the user enables Main confinement.
72
+
73
+ Pi and installed runtime extensions remain trusted code, including their
74
+ initialization, provider hooks, and internal `pi.exec` calls. The tool gate is
75
+ not a sandbox around malicious runtime extensions. Loaded runtime code,
76
+ configuration, and policy/control files are protected from task writes, even
77
+ under broader file grants. Task access to `~/.pi` does not receive a blanket
78
+ write allowance or lock-file exception.
79
+
80
+ Local confinement cannot govern a remote host's filesystem. Dedicated SSH,
81
+ MCP, scripting, background, and nested-agent tools currently lack admission
82
+ adapters and fail closed under an enabled actor profile. SSH through confined
83
+ `bash` receives local file, credential, and network restrictions; remote effects
84
+ remain outside the local filesystem policy.
65
85
 
66
86
  Overriding `write` and `edit` changes nothing you can see: the parameter
67
87
  schemas, prompt guidance, call rendering, write previews, edit diffs, result
@@ -71,7 +91,7 @@ operations underneath them are replaced.
71
91
  ## Commands
72
92
 
73
93
  ```text
74
- /sandbox show the effective status
94
+ /sandbox open the permission table (text status outside TUI)
75
95
  /sandbox on enable protection for operations started from now on
76
96
  /sandbox off turn protection off for this session (interactive confirmation)
77
97
  /sandbox default on persist opt-in and enable it now
@@ -164,9 +184,11 @@ in your rule set but is held out in that project, with a message saying so.
164
184
  ## Lifecycle
165
185
 
166
186
  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`.
187
+ startup, new session, resume, fork, or reload. Save as defaults writes both
188
+ profiles to `~/.pi/agent/extensions/pi-better-sandbox-permissions.json` (or the
189
+ corresponding `$PI_CODING_AGENT_DIR`). Existing activation preferences in
190
+ `pi-better-sandbox-preferences.json` migrate when no profile file exists.
191
+ `/sandbox default on|off` remains available and updates Main's saved switch.
170
192
 
171
193
  Toggles apply to operations launched after the change. A command already running
172
194
  keeps the policy it launched with.
@@ -198,6 +220,20 @@ that exact file, and an alias pointing at a denied file is denied too.
198
220
  | Linux | Bubblewrap (`bwrap`) | install `bubblewrap` |
199
221
  | Other | none | protected commands are blocked |
200
222
 
223
+ A detached subagent gets a private session/temp directory for Pi's runtime
224
+ state. This directory stays writable even with Outside project set to Read or
225
+ Off; other runs' state and the parent's launch metadata are not included.
226
+ Read access to system executable and library directories, device I/O, and root
227
+ directory metadata/listing remains available so a process can start. On macOS,
228
+ the allowance excludes the broad `/System/Volumes` tree.
229
+
230
+ Linux currently refuses combinations it cannot safely mount: hiding the
231
+ project or credential files inside a visible whole-filesystem bind, writing
232
+ credential stores under a read-only outside root, and a writable outside root
233
+ with protected paths. These launch errors preserve the selected restrictions.
234
+ The macOS permission combinations are covered by real-kernel tests; Linux
235
+ mount behavior requires a Linux runner with Bubblewrap and user namespaces.
236
+
201
237
  ## For other extensions
202
238
 
203
239
  The effective policy is published as a frozen snapshot on Pi's extension event
package/commands.ts CHANGED
@@ -27,7 +27,7 @@ import type { ForegroundSandboxController, ForegroundSandboxStatus } from "./sta
27
27
  export const SANDBOX_COMMAND_NAME = "sandbox";
28
28
 
29
29
  export const SANDBOX_COMMAND_DESCRIPTION =
30
- "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";
31
31
 
32
32
  const USAGE = [
33
33
  "Usage:",
@@ -80,6 +80,7 @@ export type SandboxCommandDeps = {
80
80
  onStateChange: (status: ForegroundSandboxStatus) => void;
81
81
  /** Persist a default and apply it to the current session. */
82
82
  setDefault: (enabled: boolean) => ForegroundSandboxStatus;
83
+ openPermissions?: (ctx: ExtensionCommandContext) => Promise<void>;
83
84
  };
84
85
 
85
86
  /** Build the `/sandbox` handler. Exported so its behaviour is directly testable. */
@@ -88,6 +89,7 @@ export function createSandboxCommandHandler({
88
89
  denyRules,
89
90
  onStateChange,
90
91
  setDefault,
92
+ openPermissions,
91
93
  }: SandboxCommandDeps) {
92
94
  return async function handleSandboxCommand(
93
95
  args: string,
@@ -101,6 +103,10 @@ export function createSandboxCommandHandler({
101
103
  const rest = trimmed.slice(verb.length).trim();
102
104
 
103
105
  if (subcommand === "") {
106
+ if (openPermissions && ctx.mode === "tui") {
107
+ await openPermissions(ctx);
108
+ return;
109
+ }
104
110
  ctx.ui.notify(formatSandboxReport(controller.status()), "info");
105
111
  return;
106
112
  }
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,19 +5,17 @@
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
+ * File-tool syscalls and shell commands run under the same kernel policy.
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 { dirname, isAbsolute, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
17
18
  import {
18
- createBashToolDefinition,
19
- createEditToolDefinition,
20
- createWriteToolDefinition,
21
19
  SettingsManager,
22
20
  type ExtensionAPI,
23
21
  type ExtensionContext,
@@ -34,12 +32,12 @@ import {
34
32
  FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL,
35
33
  publishForegroundSandboxPolicy,
36
34
  } from "./events.ts";
37
- import {
38
- createSandboxedEditOperations,
39
- createSandboxedWriteOperations,
40
- } from "./files.ts";
41
- import { readSandboxDefault, writeSandboxDefault } from "./preferences.ts";
42
- import { createSandboxedBashOperations } from "./shell.ts";
35
+ import { installTaskTools, runtimeCodeRoot } from "./shared-task-sandbox.ts";
36
+ import { writeSandboxDefault } from "./preferences.ts";
37
+ import { readPermissionSettings, writePermissionSettings } from "./permission-settings.ts";
38
+ import { defaultSandboxPermissions } from "./permissions.ts";
39
+ import { openPermissionsPage } from "./permissions-page.ts";
40
+
43
41
  import { footerTone, formatFooterStatus } from "./status.ts";
44
42
  import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
45
43
 
@@ -51,37 +49,36 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
51
49
  // Pi's shell setting is only readable once a session directory is known, so
52
50
  // it is resolved lazily and re-read on every session start.
53
51
  let shellPath: string | undefined;
54
- const operations = createSandboxedBashOperations(controller, { shellPath: () => shellPath });
55
-
56
- // Overriding the built-in bash tool by name. Only `operations` changes:
57
- // Pi's own definition still owns the schema, streaming, timeout,
58
- // cancellation, truncation, session environment, result details, and both
59
- // renderers, so every bash contract stays the built-in one.
60
- pi.registerTool(createBashToolDefinition(process.cwd(), { operations }));
61
-
62
- // The same backend for user-entered ! and !! commands.
63
- pi.on("user_bash", () => ({ operations }));
64
-
65
- // Overriding the built-in write and edit tools the same way: only their
66
- // file operations change, so Pi's own definitions keep the parameter
67
- // schemas, prompt guidance, call rendering, write previews, edit diffs,
68
- // result details, file-mutation queue, and cancellation checks. The guarded
69
- // operations run inside that queue, which is where the enforcement belongs.
70
- const writeOperations = createSandboxedWriteOperations(controller);
71
- const editOperations = createSandboxedEditOperations(controller);
52
+ const ownEntry = fileURLToPath(import.meta.url);
53
+ const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
54
+ trustedSources: [ownEntry,
55
+ join(dirname(ownEntry), "../../extensions/sandbox/index.ts"),
56
+ join(dirname(ownEntry), "../pi-better-harness/extensions/sandbox/index.ts")],
57
+ });
72
58
 
73
- // `cwd` is what these tools resolve a relative `path` against, so it has to
74
- // be the directory Pi itself resolves against. Registration is re-run when
75
- // a session reports a different cwd (`pi --cwd ...`), which Pi supports and
76
- // refreshes in the same session.
77
- let fileToolCwd: string | undefined;
78
- const registerFileTools = (cwd: string): void => {
79
- if (fileToolCwd === cwd) return;
80
- fileToolCwd = cwd;
81
- pi.registerTool(createWriteToolDefinition(cwd, { operations: writeOperations }));
82
- pi.registerTool(createEditToolDefinition(cwd, { operations: editOperations }));
83
- };
84
- registerFileTools(process.cwd());
59
+ pi.on("tool_call", (event) => {
60
+ const status = controller.status();
61
+ if (status.state === "inactive" || status.state === "disabled") return;
62
+ const permissions = status.permissions;
63
+ if (!permissions) return;
64
+ const name = event.toolName;
65
+ const action = (event.input as { action?: string }).action;
66
+ const launch = ["bash", "powershell", "remote_bash", "subagent_spawn", "subagent_spawn_batch", "bg_task_spawn", "bg_task_watch"].includes(name) ||
67
+ (name === "bg_task" && (action === "spawn" || action === "watch"));
68
+ if (!permissions.commands && (launch || name === "grep" || name === "find")) {
69
+ return { block: true, reason: "Sandbox: Run commands & applications is Off. Change it in /sandbox to launch work." };
70
+ }
71
+ if (!permissions.network && (["web_search", "web_fetch", "firecrawl_scrape", "firecrawl_extract", "remote_bash", "mcp", "mcpScript"].includes(name) || name.startsWith("mcp__") ||
72
+ (launch && Boolean((event.input as { ssh?: unknown }).ssh)))) {
73
+ return { block: true, reason: "Sandbox: Network access is Off." };
74
+ }
75
+ if (name === "powershell" || name === "remote_bash") {
76
+ return { block: true, reason: `Sandbox: ${name} is not a confined execution surface; use bash (including ssh through bash).` };
77
+ }
78
+ if (status.readPolicy === "restricted" && ["grep", "find", "ls"].includes(name)) {
79
+ return { block: true, reason: "Sandbox: use the guarded read tool or a confined bash command for restricted file access." };
80
+ }
81
+ });
85
82
 
86
83
  let paintFooter: ((status: ForegroundSandboxStatus) => void) | undefined;
87
84
 
@@ -102,7 +99,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
102
99
 
103
100
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
104
101
  shellPath = resolveShellPath(ctx.cwd);
105
- registerFileTools(ctx.cwd);
102
+ boundary.register(ctx.cwd);
106
103
  paintFooter = (status) => {
107
104
  ctx.ui.setStatus(
108
105
  FOOTER_KEY,
@@ -110,19 +107,28 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
110
107
  );
111
108
  };
112
109
 
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;
110
+ // Re-read saved profiles on each session. Malformed policy blocks protected
111
+ // operations rather than silently widening a restricted session.
112
+ let settings = defaultSandboxPermissions();
117
113
  try {
118
- defaultEnabled = readSandboxDefault() === "on";
114
+ settings = readPermissionSettings();
119
115
  } catch (error) {
120
- ctx.ui.notify(
121
- `Foreground sandbox preference ignored; defaulting off: ${error instanceof Error ? error.message : String(error)}`,
122
- "warning",
123
- );
116
+ controller.beginSession(ctx.cwd, true);
117
+ const message = `Sandbox permissions could not be loaded: ${error instanceof Error ? error.message : String(error)}`;
118
+ settings.main.enabled = true;
119
+ settings.main.commands = false;
120
+ settings.subagents.commands = false;
121
+ controller.setPermissionSettings(settings);
122
+ announce(controller.block(message));
123
+ ctx.ui.notify(message, "error");
124
+ return;
124
125
  }
125
- controller.beginSession(ctx.cwd, defaultEnabled);
126
+ controller.beginSession(ctx.cwd, settings.main.enabled);
127
+ controller.protectRuntimePaths((pi.getAllTools?.() ?? [])
128
+ .map((tool) => tool.sourceInfo?.path).filter((path): path is string => typeof path === "string" && isAbsolute(path))
129
+ .map(runtimeCodeRoot));
130
+ controller.setPermissionSettings(settings);
131
+ controller.applyDefault(settings.main.enabled);
126
132
 
127
133
  // Then the rules are re-read and re-resolved, because the same global
128
134
  // template set means different absolute paths in a different project.
@@ -160,8 +166,18 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
160
166
  denyRules,
161
167
  onStateChange: announce,
162
168
  setDefault: (enabled) => {
169
+ const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
170
+ settings.main.enabled = enabled;
171
+ writePermissionSettings(settings);
163
172
  writeSandboxDefault(enabled ? "on" : "off");
164
- return controller.applyDefault(enabled);
173
+ return controller.setPermissionSettings(settings);
174
+ },
175
+ openPermissions: async (ctx) => {
176
+ await openPermissionsPage(ctx, {
177
+ getConfig: () => controller.permissionSettings() ?? defaultSandboxPermissions(),
178
+ change: (settings) => announce(controller.setPermissionSettings(settings)),
179
+ save: (settings) => writePermissionSettings(settings),
180
+ });
165
181
  },
166
182
  }),
167
183
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.3.0",
4
- "description": "Pi extension that confines foreground shell execution to the project directory with a kernel-enforced write sandbox.",
3
+ "version": "0.5.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",
@@ -31,9 +31,9 @@
31
31
  "access": "public"
32
32
  },
33
33
  "scripts": {
34
- "pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs",
35
- "pretest": "node ../../scripts/sync-shared-sandbox-core.mjs",
36
- "prepack": "node ../../scripts/sync-shared-sandbox-core.mjs",
34
+ "pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
35
+ "pretest": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
36
+ "prepack": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
37
37
  "typecheck": "tsc --noEmit",
38
38
  "test": "node --import tsx --test test/*.test.ts",
39
39
  "verify": "npm run typecheck && npm test"
@@ -44,7 +44,7 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": "*",
47
+ "@earendil-works/pi-coding-agent": ">=0.82.1",
48
48
  "@earendil-works/pi-tui": "*",
49
49
  "typebox": "*"
50
50
  },
@@ -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
+ }