pi-better-sandbox 0.1.1 → 0.2.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
- A default-on write sandbox for Pi's foreground tools.
3
+ An opt-in write sandbox for Pi's foreground tools.
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,11 +9,12 @@ 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, no wrapper command, and nothing to configure. From the first
13
- session start, Pi's built-in `bash` tool and the `!` / `!!` commands you type
14
- yourself run inside an OS sandbox that lets them write only under the directory
15
- you launched Pi from, and the built-in `write` and `edit` tools are held to the
16
- same policy.
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.
17
18
 
18
19
  ```
19
20
  Read: every filesystem path
@@ -71,8 +72,10 @@ operations underneath them are replaced.
71
72
 
72
73
  ```text
73
74
  /sandbox show the effective status
74
- /sandbox on re-arm protection for operations started from now on
75
+ /sandbox on enable protection for operations started from now on
75
76
  /sandbox off turn protection off for this session (interactive confirmation)
77
+ /sandbox default on persist opt-in and enable it now
78
+ /sandbox default off persist opt-out (interactive confirmation)
76
79
  /sandbox deny list show the write-denied paths
77
80
  /sandbox deny add <path> stop allowing writes to a path
78
81
  /sandbox deny remove <path> allow writes to a path again
@@ -80,15 +83,17 @@ operations underneath them are replaced.
80
83
  /sandbox rules open the write-denied paths editor
81
84
  ```
82
85
 
83
- The footer shows `sandbox · on · <project>` while protection is active, and a
84
- prominent `sandbox · OFF`, `sandbox · UNAVAILABLE`, or `sandbox · FAILED`
85
- otherwise. Both surfaces report what the runtime actually resolved — which
86
- backend, which executable — never what was merely configured.
86
+ The footer shows `sandbox · available` when a backend is available but inactive,
87
+ `sandbox · inactive` when inactive without a backend, and
88
+ `sandbox · on · <project>` while protection is active. Explicitly enabled
89
+ sessions report `UNAVAILABLE` or `FAILED` when protection cannot be applied.
90
+ Both surfaces report what the runtime actually resolved — which backend, which
91
+ executable — never what was merely configured.
87
92
 
88
- `/sandbox off` needs an interactive confirmation and is refused outright when
89
- there is no interactive UI. There is no tool for changing sandbox state or its
90
- rules, so the model can neither disable its own confinement nor edit the paths
91
- it is confined away from.
93
+ `/sandbox off` and `/sandbox default off` need interactive confirmation and are
94
+ refused outright when there is no interactive UI. There is no tool for changing
95
+ sandbox state or its rules, so the model cannot change confinement or edit the
96
+ paths it is confined away from.
92
97
 
93
98
  ## Write-denied paths
94
99
 
@@ -158,17 +163,19 @@ in your rule set but is held out in that project, with a message saying so.
158
163
 
159
164
  ## Lifecycle
160
165
 
161
- The sandbox is enabled again at every session start: startup, new session,
162
- resume, fork, and reload. An off state is never written anywhere, so it cannot
163
- outlive the session you switched it off in.
166
+ 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`.
164
170
 
165
171
  Toggles apply to operations launched after the change. A command already running
166
172
  keeps the policy it launched with.
167
173
 
168
174
  ## Fail-closed behaviour
169
175
 
170
- While the sandbox is enabled and a backend cannot be applied, protected commands
171
- and file mutations are **blocked** rather than run unprotected:
176
+ While the sandbox is explicitly or persistently enabled and a backend cannot be
177
+ applied, protected commands and file mutations are **blocked** rather than run
178
+ unprotected:
172
179
 
173
180
  - No backend on this platform (`unavailable`).
174
181
  - A launch directory too broad to confine — `/` or your home directory
package/commands.ts CHANGED
@@ -26,13 +26,15 @@ import type { ForegroundSandboxController, ForegroundSandboxStatus } from "./sta
26
26
  export const SANDBOX_COMMAND_NAME = "sandbox";
27
27
 
28
28
  export const SANDBOX_COMMAND_DESCRIPTION =
29
- "Show the foreground write sandbox, turn it on or off for this session, or manage write-denied paths";
29
+ "Show the foreground write sandbox, change session or persistent activation, or manage write-denied paths";
30
30
 
31
31
  const USAGE = [
32
32
  "Usage:",
33
33
  " /sandbox",
34
34
  " /sandbox on",
35
35
  " /sandbox off",
36
+ " /sandbox default on",
37
+ " /sandbox default off",
36
38
  " /sandbox deny list",
37
39
  " /sandbox deny add <path>",
38
40
  " /sandbox deny remove <path>",
@@ -52,12 +54,20 @@ const DISABLE_TITLE = "Disable the foreground write sandbox?";
52
54
 
53
55
  const DISABLE_MESSAGE = [
54
56
  "The built-in bash, write, and edit tools and user-entered ! / !! commands",
55
- "will run with normal host write access for the rest of this session. New,",
56
- "resumed, forked, and reloaded sessions start protected again.",
57
+ "will run with normal host write access for the rest of this session. The",
58
+ "next session applies your persisted foreground sandbox default.",
57
59
  ].join("\n");
58
60
 
59
61
  const NO_UI_REJECTION =
60
- "/sandbox off needs an interactive confirmation and there is no interactive UI here, so the sandbox stays on.";
62
+ "/sandbox off needs an interactive confirmation and there is no interactive UI here, so the sandbox state is unchanged.";
63
+
64
+ const DEFAULT_OFF_TITLE = "Keep the foreground write sandbox off by default?";
65
+
66
+ const DEFAULT_OFF_MESSAGE = [
67
+ "This session and future sessions will run foreground tools and local background",
68
+ "tasks with normal host write access until you run /sandbox on or change the",
69
+ "persistent default.",
70
+ ].join("\n");
61
71
 
62
72
  const RESET_TITLE = "Restore the packaged write-deny defaults?";
63
73
 
@@ -67,6 +77,8 @@ export type SandboxCommandDeps = {
67
77
  denyRules: DenyRuleManager;
68
78
  /** Called after any state change so the footer and consumers stay truthful. */
69
79
  onStateChange: (status: ForegroundSandboxStatus) => void;
80
+ /** Persist a default and apply it to the current session. */
81
+ setDefault: (enabled: boolean) => ForegroundSandboxStatus;
70
82
  };
71
83
 
72
84
  /** Build the `/sandbox` handler. Exported so its behaviour is directly testable. */
@@ -74,6 +86,7 @@ export function createSandboxCommandHandler({
74
86
  controller,
75
87
  denyRules,
76
88
  onStateChange,
89
+ setDefault,
77
90
  }: SandboxCommandDeps) {
78
91
  return async function handleSandboxCommand(
79
92
  args: string,
@@ -97,13 +110,20 @@ export function createSandboxCommandHandler({
97
110
  ctx.ui.notify(
98
111
  status.state === "enabled"
99
112
  ? `Foreground sandbox on. ${status.reason}`
100
- : `Foreground sandbox re-armed but not active: ${status.reason}`,
113
+ : `Foreground sandbox requested but not active: ${status.reason}`,
101
114
  status.state === "enabled" ? "info" : "warning",
102
115
  );
103
116
  return;
104
117
  }
105
118
 
106
119
  if (subcommand === "off") {
120
+ if (controller.status().state === "inactive") {
121
+ ctx.ui.notify(
122
+ "Foreground sandbox is already inactive by default. Use /sandbox default on to opt in persistently.",
123
+ "info",
124
+ );
125
+ return;
126
+ }
107
127
  if (!ctx.hasUI) {
108
128
  ctx.ui.notify(NO_UI_REJECTION, "error");
109
129
  return;
@@ -122,6 +142,44 @@ export function createSandboxCommandHandler({
122
142
  return;
123
143
  }
124
144
 
145
+ if (subcommand === "default") {
146
+ const mode = tail[0]?.toLowerCase();
147
+ if (mode !== "on" && mode !== "off") {
148
+ ctx.ui.notify("/sandbox default needs on or off.\n\n" + USAGE, "error");
149
+ return;
150
+ }
151
+ if (mode === "off") {
152
+ if (!ctx.hasUI) {
153
+ ctx.ui.notify(
154
+ "/sandbox default off needs an interactive confirmation and there is no interactive UI here.",
155
+ "error",
156
+ );
157
+ return;
158
+ }
159
+ const confirmed = await ctx.ui.confirm(DEFAULT_OFF_TITLE, DEFAULT_OFF_MESSAGE);
160
+ if (!confirmed) {
161
+ ctx.ui.notify("The sandbox default was left unchanged.", "info");
162
+ return;
163
+ }
164
+ }
165
+ try {
166
+ const status = setDefault(mode === "on");
167
+ onStateChange(status);
168
+ ctx.ui.notify(
169
+ mode === "on"
170
+ ? `Foreground sandbox default is ON. ${status.reason}`
171
+ : "Foreground sandbox default is off. Foreground tools and local background tasks are unconfined.",
172
+ mode === "on" && status.state !== "enabled" ? "warning" : "info",
173
+ );
174
+ } catch (error) {
175
+ ctx.ui.notify(
176
+ `Could not save the sandbox default: ${error instanceof Error ? error.message : String(error)}`,
177
+ "error",
178
+ );
179
+ }
180
+ return;
181
+ }
182
+
125
183
  if (subcommand === "deny") {
126
184
  await handleDeny(denyRules, ctx, tail[0]?.toLowerCase() ?? "list", rest);
127
185
  return;
@@ -195,20 +253,27 @@ function announce(ctx: ExtensionCommandContext, change: () => DenyRuleReport): v
195
253
  }
196
254
  }
197
255
 
198
- const SUBCOMMANDS = ["on", "off", "deny", "rules"] as const;
256
+ const SUBCOMMANDS = ["on", "off", "default", "deny", "rules"] as const;
199
257
  const DENY_ACTIONS = ["list", "add", "remove", "reset"] as const;
200
258
 
201
259
  /** Argument completions for `/sandbox`, including the `deny` actions. */
202
260
  export function sandboxArgumentCompletions(argumentPrefix: string) {
203
261
  const prefix = argumentPrefix.trimStart().toLowerCase();
204
262
  const denyPrefix = /^deny(\s|$)/.test(prefix) ? prefix.replace(/^deny\s*/, "") : undefined;
263
+ const defaultPrefix = /^default(\s|$)/.test(prefix)
264
+ ? prefix.replace(/^default\s*/, "")
265
+ : undefined;
205
266
 
206
267
  const values =
207
- denyPrefix === undefined
208
- ? SUBCOMMANDS.filter((value) => value.startsWith(prefix))
209
- : DENY_ACTIONS.filter((value) => value.startsWith(denyPrefix)).map(
268
+ denyPrefix !== undefined
269
+ ? DENY_ACTIONS.filter((value) => value.startsWith(denyPrefix)).map(
210
270
  (value) => `deny ${value}`,
211
- );
271
+ )
272
+ : defaultPrefix !== undefined
273
+ ? ["on", "off"]
274
+ .filter((value) => value.startsWith(defaultPrefix))
275
+ .map((value) => `default ${value}`)
276
+ : SUBCOMMANDS.filter((value) => value.startsWith(prefix));
212
277
 
213
278
  return values.map((value) => ({ value, label: value }));
214
279
  }
package/events.ts CHANGED
@@ -21,12 +21,24 @@ export const FOREGROUND_SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
21
21
  /** Channel a late-loading consumer emits on to ask for the current policy. */
22
22
  export const FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
23
23
 
24
- /** The immutable payload published on the policy channel. */
25
- export type ForegroundSandboxPolicyEvent = ForegroundSandboxStatus;
24
+ /**
25
+ * The immutable payload published on the policy channel.
26
+ *
27
+ * `inactive` is a foreground presentation state. Consumers only need the
28
+ * enforcement decision, so it is published as the existing `disabled` state.
29
+ * This keeps older background-task versions fail-safe during package skew.
30
+ */
31
+ export type ForegroundSandboxPolicyEvent = Omit<ForegroundSandboxStatus, "state"> & {
32
+ readonly state: Exclude<ForegroundSandboxStatus["state"], "inactive">;
33
+ };
26
34
 
27
35
  /** Deep-freeze a status so a consumer cannot mutate another consumer's copy. */
28
36
  export function freezePolicy(status: ForegroundSandboxStatus): ForegroundSandboxPolicyEvent {
29
- return Object.freeze({ ...status, denyWrite: Object.freeze([...status.denyWrite]) });
37
+ return Object.freeze({
38
+ ...status,
39
+ state: status.state === "inactive" ? "disabled" : status.state,
40
+ denyWrite: Object.freeze([...status.denyWrite]),
41
+ });
30
42
  }
31
43
 
32
44
  /** Publish the current effective policy to every subscribed extension. */
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * pi-better-sandbox - a default-on write sandbox for foreground tool execution.
2
+ * pi-better-sandbox - an opt-in write sandbox for foreground tool execution.
3
3
  *
4
4
  * Installing this package loads an extension; it ships no launcher, so users
5
5
  * keep starting Pi with plain `pi`. While enabled, the built-in `bash` tool and
@@ -38,6 +38,7 @@ import {
38
38
  createSandboxedEditOperations,
39
39
  createSandboxedWriteOperations,
40
40
  } from "./files.ts";
41
+ import { readSandboxDefault, writeSandboxDefault } from "./preferences.ts";
41
42
  import { createSandboxedBashOperations } from "./shell.ts";
42
43
  import { footerTone, formatFooterStatus } from "./status.ts";
43
44
  import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
@@ -109,10 +110,19 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
109
110
  );
110
111
  };
111
112
 
112
- // Every session start re-captures the project root and re-arms
113
- // protection, so an earlier /sandbox off never survives into a new,
114
- // resumed, forked, or reloaded session.
115
- controller.beginSession(ctx.cwd);
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;
117
+ try {
118
+ defaultEnabled = readSandboxDefault() === "on";
119
+ } catch (error) {
120
+ ctx.ui.notify(
121
+ `Foreground sandbox preference ignored; defaulting off: ${error instanceof Error ? error.message : String(error)}`,
122
+ "warning",
123
+ );
124
+ }
125
+ controller.beginSession(ctx.cwd, defaultEnabled);
116
126
 
117
127
  // Then the rules are re-read and re-resolved, because the same global
118
128
  // template set means different absolute paths in a different project.
@@ -130,7 +140,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
130
140
  "warning",
131
141
  );
132
142
  }
133
- if (status.state !== "enabled") {
143
+ if (status.state !== "enabled" && status.state !== "inactive") {
134
144
  ctx.ui.notify(
135
145
  `Foreground sandbox ${status.state}: ${status.reason}`,
136
146
  status.state === "disabled" ? "info" : "warning",
@@ -145,7 +155,15 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
145
155
  pi.registerCommand(SANDBOX_COMMAND_NAME, {
146
156
  description: SANDBOX_COMMAND_DESCRIPTION,
147
157
  getArgumentCompletions: sandboxArgumentCompletions,
148
- handler: createSandboxCommandHandler({ controller, denyRules, onStateChange: announce }),
158
+ handler: createSandboxCommandHandler({
159
+ controller,
160
+ denyRules,
161
+ onStateChange: announce,
162
+ setDefault: (enabled) => {
163
+ writeSandboxDefault(enabled ? "on" : "off");
164
+ return controller.applyDefault(enabled);
165
+ },
166
+ }),
149
167
  });
150
168
  }
151
169
 
@@ -201,6 +219,16 @@ export {
201
219
  readDenyRuleOverride,
202
220
  writeDenyRuleOverride,
203
221
  } from "./deny-rules.ts";
222
+ export {
223
+ readSandboxDefault,
224
+ SANDBOX_PREFERENCES_FILE_NAME,
225
+ SANDBOX_PREFERENCES_FORMAT_VERSION,
226
+ SandboxPreferenceError,
227
+ sandboxPreferencesPath,
228
+ type SandboxDefaultMode,
229
+ type SandboxPreferenceSeams,
230
+ writeSandboxDefault,
231
+ } from "./preferences.ts";
204
232
  export { openSandboxRulesPage, RULES_PAGE_NO_UI_REJECTION } from "./rules-page.ts";
205
233
  export {
206
234
  describeUnsafeProjectRoot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension that confines foreground shell execution to the project directory with a kernel-enforced write sandbox.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/preferences.ts ADDED
@@ -0,0 +1,93 @@
1
+ /** Persisted foreground-sandbox activation preference. */
2
+
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
+
8
+ export const SANDBOX_PREFERENCES_FILE_NAME = "pi-better-sandbox-preferences.json";
9
+ export const SANDBOX_PREFERENCES_FORMAT_VERSION = 1;
10
+
11
+ export type SandboxDefaultMode = "off" | "on";
12
+
13
+ export type SandboxPreferenceSeams = {
14
+ agentDir?: () => string;
15
+ };
16
+
17
+ type SandboxPreferencesFile = {
18
+ version: number;
19
+ default: SandboxDefaultMode;
20
+ };
21
+
22
+ export class SandboxPreferenceError extends Error {
23
+ constructor(message: string) {
24
+ super(message);
25
+ this.name = "SandboxPreferenceError";
26
+ }
27
+ }
28
+
29
+ export function sandboxPreferencesPath(seams: SandboxPreferenceSeams = {}): string {
30
+ return join((seams.agentDir ?? getAgentDir)(), "extensions", SANDBOX_PREFERENCES_FILE_NAME);
31
+ }
32
+
33
+ /** Read the persisted default. No file means the product default: off. */
34
+ export function readSandboxDefault(seams: SandboxPreferenceSeams = {}): SandboxDefaultMode {
35
+ const path = sandboxPreferencesPath(seams);
36
+ let raw: string;
37
+ try {
38
+ raw = readFileSync(path, "utf8");
39
+ } catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "off";
41
+ throw new SandboxPreferenceError(
42
+ `The sandbox preference at ${path} could not be read: ${messageOf(error)}`,
43
+ );
44
+ }
45
+
46
+ let parsed: unknown;
47
+ try {
48
+ parsed = JSON.parse(raw);
49
+ } catch (error) {
50
+ throw new SandboxPreferenceError(
51
+ `The sandbox preference at ${path} is not valid JSON: ${messageOf(error)}`,
52
+ );
53
+ }
54
+
55
+ const value = parsed as Partial<SandboxPreferencesFile> | null;
56
+ if (
57
+ value?.version !== SANDBOX_PREFERENCES_FORMAT_VERSION ||
58
+ (value.default !== "off" && value.default !== "on")
59
+ ) {
60
+ throw new SandboxPreferenceError(
61
+ `The sandbox preference at ${path} must contain version ${SANDBOX_PREFERENCES_FORMAT_VERSION} and default "off" or "on".`,
62
+ );
63
+ }
64
+ return value.default;
65
+ }
66
+
67
+ /** Atomically persist the default used by future sessions. */
68
+ export function writeSandboxDefault(
69
+ mode: SandboxDefaultMode,
70
+ seams: SandboxPreferenceSeams = {},
71
+ ): string {
72
+ const path = sandboxPreferencesPath(seams);
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ const contents = `${JSON.stringify(
75
+ { version: SANDBOX_PREFERENCES_FORMAT_VERSION, default: mode } satisfies SandboxPreferencesFile,
76
+ undefined,
77
+ 2,
78
+ )}\n`;
79
+ const pending = `${path}.${process.pid}.tmp`;
80
+ try {
81
+ writeFileSync(pending, contents, "utf8");
82
+ renameSync(pending, path);
83
+ } catch (error) {
84
+ throw new SandboxPreferenceError(
85
+ `The sandbox preference at ${path} could not be written: ${messageOf(error)}`,
86
+ );
87
+ }
88
+ return path;
89
+ }
90
+
91
+ function messageOf(error: unknown): string {
92
+ return error instanceof Error ? error.message : String(error);
93
+ }
package/state.ts CHANGED
@@ -2,13 +2,13 @@
2
2
  * Session-local foreground sandbox state.
3
3
  *
4
4
  * One controller per Pi session owns three things: the canonical project root
5
- * captured at session start, whether a human has switched protection off, and
5
+ * captured at session start, the persisted default plus session override, and
6
6
  * the *effective* status derived from live runtime evidence (which backend this
7
7
  * platform actually resolves, not what the package intended).
8
8
  *
9
- * The enabled state is deliberately in-memory only. Every session start —
10
- * startup, new, resume, fork, reload — calls `beginSession` and lands back on
11
- * enabled, which is what "an off state is never persisted" means in practice.
9
+ * Session overrides are deliberately in-memory only. Every session start —
10
+ * startup, new, resume, fork, reload — calls `beginSession` with the persisted
11
+ * default and clears the previous override.
12
12
  */
13
13
 
14
14
  import { createHash } from "node:crypto";
@@ -32,13 +32,14 @@ import {
32
32
  /**
33
33
  * What the foreground sandbox is actually doing right now.
34
34
  *
35
+ * - `inactive` - default-off; protected operations run unconfined.
35
36
  * - `enabled` - a backend is resolved and protected operations are wrapped.
36
37
  * - `disabled` - a human turned it off for this session.
37
38
  * - `unavailable` - this platform resolves no backend; protected operations are blocked.
38
39
  * - `failed` - protection cannot be applied here (no session yet, or an
39
40
  * unsafe launch root); protected operations are blocked.
40
41
  */
41
- export type ForegroundSandboxState = "enabled" | "disabled" | "unavailable" | "failed";
42
+ export type ForegroundSandboxState = "inactive" | "enabled" | "disabled" | "unavailable" | "failed";
42
43
 
43
44
  /**
44
45
  * The immutable effective-policy snapshot published to first-party consumers
@@ -109,7 +110,8 @@ export class ForegroundSandboxController {
109
110
  #unsafeRootReason: string | undefined;
110
111
  #denyWrite: readonly string[] = [];
111
112
  #denyTemplates: readonly string[] = PACKAGED_DENY_WRITE_TEMPLATES;
112
- #userEnabled = true;
113
+ #defaultEnabled = false;
114
+ #sessionOverride: boolean | undefined;
113
115
  #profileDir: string | undefined;
114
116
 
115
117
  constructor(seams: ForegroundSandboxSeams = {}) {
@@ -117,12 +119,10 @@ export class ForegroundSandboxController {
117
119
  }
118
120
 
119
121
  /**
120
- * Capture the canonical launch directory and re-arm protection.
121
- *
122
- * Called for every session start reason, which is what keeps a previous
123
- * `/sandbox off` from surviving a new, resumed, forked, or reloaded session.
122
+ * Capture the canonical launch directory and apply the persisted default.
123
+ * A session override never survives a new, resumed, forked, or reloaded session.
124
124
  */
125
- beginSession(cwd: string): ForegroundSandboxStatus {
125
+ beginSession(cwd: string, defaultEnabled = false): ForegroundSandboxStatus {
126
126
  const projectRoot = canonicalizePath(cwd, this.#seams);
127
127
  this.#projectRoot = projectRoot;
128
128
  this.#unsafeRootReason = describeUnsafeProjectRoot(projectRoot, this.#seams);
@@ -131,25 +131,33 @@ export class ForegroundSandboxController {
131
131
  ? []
132
132
  : resolveDenyWriteTemplates(this.#denyTemplates, projectRoot, this.#seams),
133
133
  );
134
- this.#userEnabled = true;
134
+ this.#defaultEnabled = defaultEnabled;
135
+ this.#sessionOverride = undefined;
135
136
  return this.status();
136
137
  }
137
138
 
138
139
  /** Re-enable protection for operations launched from now on. */
139
140
  enable(): ForegroundSandboxStatus {
140
- this.#userEnabled = true;
141
+ this.#sessionOverride = true;
141
142
  return this.status();
142
143
  }
143
144
 
144
145
  /** Turn protection off for this session only. Never persisted. */
145
146
  disable(): ForegroundSandboxStatus {
146
- this.#userEnabled = false;
147
+ this.#sessionOverride = false;
148
+ return this.status();
149
+ }
150
+
151
+ /** Apply a newly persisted default immediately and clear the session override. */
152
+ applyDefault(defaultEnabled: boolean): ForegroundSandboxStatus {
153
+ this.#defaultEnabled = defaultEnabled;
154
+ this.#sessionOverride = undefined;
147
155
  return this.status();
148
156
  }
149
157
 
150
158
  /** Whether a human has left protection switched on. */
151
159
  isUserEnabled(): boolean {
152
- return this.#userEnabled;
160
+ return this.#sessionOverride ?? this.#defaultEnabled;
153
161
  }
154
162
 
155
163
  /** The deny-write templates currently in force (packaged defaults for now). */
@@ -194,14 +202,19 @@ export class ForegroundSandboxController {
194
202
  });
195
203
  }
196
204
 
197
- if (!this.#userEnabled) {
205
+ if (!this.isUserEnabled()) {
206
+ const explicitlyDisabled = this.#sessionOverride === false;
198
207
  return Object.freeze({
199
208
  ...base,
200
- state: "disabled",
209
+ state: explicitlyDisabled ? "disabled" : "inactive",
201
210
  writableRoot: undefined,
202
211
  backend: support.supported ? support.backend : undefined,
203
212
  executable: support.supported ? support.executable : undefined,
204
- reason: "A human turned the foreground sandbox off for this session with /sandbox off.",
213
+ reason: explicitlyDisabled
214
+ ? "A human turned the foreground sandbox off for this session with /sandbox off."
215
+ : support.supported
216
+ ? "The foreground sandbox is available but inactive by default. Use /sandbox on for this session or /sandbox default on to persist opt-in."
217
+ : `The foreground sandbox is inactive by default, and no backend is available: ${support.reason}`,
205
218
  });
206
219
  }
207
220
 
@@ -240,13 +253,12 @@ export class ForegroundSandboxController {
240
253
  /**
241
254
  * Decide how to launch one protected operation.
242
255
  *
243
- * Returns an unconfined plan only when a human explicitly disabled the
244
- * sandbox. Every other non-enabled state throws, so a missing or unusable
245
- * backend blocks the operation rather than silently degrading it.
256
+ * Returns an unconfined plan while the sandbox is inactive or explicitly
257
+ * disabled. Once enabled, missing or unusable backends fail closed.
246
258
  */
247
259
  requireLaunchPlan(): ForegroundSandboxLaunchPlan {
248
260
  const status = this.status();
249
- if (status.state === "disabled") return { confined: false };
261
+ if (status.state === "inactive" || status.state === "disabled") return { confined: false };
250
262
  if (status.state !== "enabled" || status.writableRoot === undefined) {
251
263
  throw new ForegroundSandboxBlockedError(status);
252
264
  }
package/status.ts CHANGED
@@ -22,6 +22,7 @@ const plain: StatusPainter = (_tone, text) => text;
22
22
  /** The tone the footer uses for a given state. */
23
23
  export function footerTone(status: ForegroundSandboxStatus): StatusTone {
24
24
  if (status.state === "enabled") return "accent";
25
+ if (status.state === "inactive") return status.backend === undefined ? "warning" : "accent";
25
26
  if (status.state === "unavailable") return "warning";
26
27
  return "error";
27
28
  }
@@ -40,6 +41,9 @@ export function formatFooterStatus(
40
41
  if (status.state === "enabled" && status.writableRoot !== undefined) {
41
42
  return paint(tone, `sandbox · on · ${basename(status.writableRoot)}`);
42
43
  }
44
+ if (status.state === "inactive") {
45
+ return paint(tone, status.backend === undefined ? "sandbox · inactive" : "sandbox · available");
46
+ }
43
47
  if (status.state === "disabled") return paint(tone, "sandbox · OFF");
44
48
  if (status.state === "unavailable") return paint(tone, "sandbox · UNAVAILABLE");
45
49
  return paint(tone, "sandbox · FAILED");