pi-better-harness 0.1.26 → 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,12 +1,12 @@
1
1
  # pi-better-harness
2
2
 
3
- `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: a default-on write sandbox, delegated subagents, durable background tasks, and goal tracking.
3
+ `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: an opt-in foreground write sandbox, delegated subagents, durable background tasks, and goal tracking.
4
4
 
5
5
  ## Quick Answer
6
6
 
7
7
  Use `pi-better-harness` when you want the full working set for Pi. It manages:
8
8
 
9
- - `pi-better-sandbox` for a default-on write sandbox around Pi's foreground tools.
9
+ - `pi-better-sandbox` for an opt-in write sandbox around Pi's foreground tools.
10
10
  - `pi-better-subagents` for detached, sandboxed subagent runs.
11
11
  - `pi-better-background-tasks` for durable shell tasks and watchers.
12
12
  - `pi-better-goal` for objective tracking that is aware of background work.
@@ -45,13 +45,18 @@ You keep launching Pi the way you always have:
45
45
  pi
46
46
  ```
47
47
 
48
- The write sandbox arms itself at every session start — startup, new session, resume, fork, and reload. There is no launcher and no settings file to create.
48
+ The foreground write sandbox starts inactive. Use `/sandbox on` for the current
49
+ session or `/sandbox default on` to persist opt-in across startup, new session,
50
+ resume, fork, and reload. There is no launcher.
49
51
 
50
52
  While it is on, Pi's built-in `bash`, `write`, and `edit` tools, your own `!` / `!!` commands, local background tasks, and subagents can write only under the directory you launched Pi from, minus the packaged deny paths (`.git/hooks`, `.env`, `.env.local`).
51
53
 
52
54
  **Reads and network access are unrestricted** — this sandbox limits writes only. Writes are confined for those integrated first-party execution paths; Pi's own process, arbitrary `pi.exec` calls, and unrelated third-party extension code are **not** confined. Confinement is also **per surface**: each integrated surface denies its own control plane, not every other surface's, so with several first-party surfaces installed a confined process on one can still write another's control plane.
53
55
 
54
- Sandbox state is human-only: `/sandbox`, `/sandbox on`, `/sandbox off`, `/sandbox deny ...`, and `/sandbox rules` are slash commands with no tool equivalent, so the model cannot turn off its own confinement. `/sandbox off` needs an interactive confirmation and never persists past the session. Full policy: [pi-better-sandbox](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme).
56
+ Sandbox state is human-only: `/sandbox`, `/sandbox on`, `/sandbox off`,
57
+ `/sandbox default on|off`, `/sandbox deny ...`, and `/sandbox rules` are slash
58
+ commands with no tool equivalent. `/sandbox off` and `/sandbox default off`
59
+ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme).
55
60
 
56
61
  ## When To Use
57
62
 
package/lib/cli.mjs CHANGED
@@ -7,8 +7,8 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
7
  const { version } = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
8
8
 
9
9
  export const componentPackages = [
10
- // The sandbox is configured first so a fresh install has write protection in
11
- // place before the extensions whose work it confines.
10
+ // The foreground policy publisher is configured before the extensions that
11
+ // consume its active or inactive launch decision.
12
12
  "pi-better-sandbox",
13
13
  "pi-better-subagents",
14
14
  "pi-better-background-tasks",
@@ -27,8 +27,10 @@ the effective foreground policy at launch and runs under the platform's write
27
27
  sandbox: reads and network stay unrestricted, writes are confined to the
28
28
  canonical project directory, and denied paths stay denied.
29
29
 
30
- The policy is captured once, when the task starts. A later `/sandbox on`,
31
- `/sandbox off`, or deny-rule change reaches tasks launched after it; a task
30
+ The policy is captured once, when the task starts. The foreground sandbox is
31
+ inactive by default, so local tasks ordinarily launch unconfined. A later
32
+ `/sandbox on`, `/sandbox off`, `/sandbox default on|off`, or a deny-rule change
33
+ reaches tasks launched after it; a task
32
34
  already running — including a watcher resumed in a later Pi session — keeps the
33
35
  policy it started with.
34
36
 
@@ -45,8 +47,8 @@ process, `pi.exec` calls, and unrelated third-party extension code stay outside
45
47
  the guarantee, and confinement is per surface: a confined process on another
46
48
  first-party surface can still write this one's task registry. Installing
47
49
  [`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme)
48
- brings the sandbox in by default, so an ordinary `pi` session confines its
49
- foreground tools and its local background tasks under one project policy.
50
+ installs the sandbox extension, but leaves foreground tools and local background
51
+ tasks inactive until a human opts in.
50
52
 
51
53
  ## Remote SSH
52
54
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,10 +12,10 @@
12
12
  * argv are resolved once, when the task starts, and are what the task keeps
13
13
  * running. A later `/sandbox off` or deny-rule change therefore reaches only
14
14
  * tasks launched after it.
15
- * 2. **Fail closed.** Once the foreground state says a sandbox should apply, a
16
- * missing or unusable backend blocks the launch. The local command is never
17
- * retried unconfined behind the operator's back. The single exception is an
18
- * explicitly `disabled` state, which is a human's deliberate decision.
15
+ * 2. **Opt-in, then fail closed.** `inactive` and explicitly `disabled` states
16
+ * launch unconfined. Once foreground policy says confinement applies, a
17
+ * missing or unusable backend blocks the launch and is never retried
18
+ * unconfined behind the operator's back.
19
19
  *
20
20
  * The contract is duplicated here rather than imported: `pi-better-sandbox` is
21
21
  * an optional peer that this package must keep working without. Two channel
@@ -53,12 +53,13 @@ export const FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:poli
53
53
  /**
54
54
  * What the foreground sandbox is doing right now.
55
55
  *
56
+ * - `inactive` - default-off foreground policy; launch tasks as before.
56
57
  * - `enabled` - confine locally launched tasks.
57
58
  * - `disabled` - a human switched protection off; launch tasks as before.
58
59
  * - `unavailable` - no backend on this platform; block protected launches.
59
60
  * - `failed` - protection cannot be applied here; block protected launches.
60
61
  */
61
- export type ForegroundSandboxState = "enabled" | "disabled" | "unavailable" | "failed";
62
+ export type ForegroundSandboxState = "inactive" | "enabled" | "disabled" | "unavailable" | "failed";
62
63
 
63
64
  /** The published snapshot, narrowed to the fields a task launch needs. */
64
65
  export interface ForegroundSandboxPolicy {
@@ -101,7 +102,7 @@ export type ForegroundSandboxPlan =
101
102
 
102
103
  const UNCONFINED: ForegroundSandboxPlan = { confined: false };
103
104
 
104
- const VALID_STATES = new Set<string>(["enabled", "disabled", "unavailable", "failed"]);
105
+ const VALID_STATES = new Set<string>(["inactive", "enabled", "disabled", "unavailable", "failed"]);
105
106
 
106
107
  /**
107
108
  * The latest snapshot per event bus.
@@ -183,8 +184,8 @@ export function currentForegroundSandboxPolicy(pi: unknown): ForegroundSandboxPo
183
184
  * Decide how a local launch must be confined, before the task has an id, a
184
185
  * directory, or a log.
185
186
  *
186
- * Throws for every state that is neither confinable nor a human's explicit
187
- * opt-out, which is what keeps a blocked launch from leaving task state behind.
187
+ * Throws for every state that is neither confinable nor intentionally
188
+ * unconfined, which keeps a blocked launch from leaving task state behind.
188
189
  */
189
190
  export function resolveForegroundSandboxPlan(pi: unknown): ForegroundSandboxPlan {
190
191
  return planFor(currentForegroundSandboxPolicy(pi));
@@ -195,7 +196,7 @@ export function planFor(policy: ForegroundSandboxPolicy | undefined): Foreground
195
196
  // No sandbox extension is publishing: this package is installed on its own and
196
197
  // keeps its historical unsandboxed behaviour.
197
198
  if (!policy) return UNCONFINED;
198
- if (policy.state === "disabled") return UNCONFINED;
199
+ if (policy.state === "inactive" || policy.state === "disabled") return UNCONFINED;
199
200
  if (policy.state !== "enabled" || !policy.writableRoot) {
200
201
  throw new ForegroundSandboxBlockedError(policy);
201
202
  }
@@ -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
@@ -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
  }
@@ -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. */
@@ -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,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.1.0",
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",
@@ -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
+ }
@@ -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
  }
@@ -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");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.1.26",
4
- "description": "Pi extension bundle for a default write sandbox, subagents, durable background tasks, and goal tracking.",
3
+ "version": "0.2.0",
4
+ "description": "Pi extension bundle for an opt-in foreground write sandbox, subagents, durable background tasks, and goal tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "keywords": [
@@ -45,9 +45,9 @@
45
45
  "test": "node --test test/*.test.mjs"
46
46
  },
47
47
  "dependencies": {
48
- "pi-better-background-tasks": "0.2.6",
48
+ "pi-better-background-tasks": "0.2.7",
49
49
  "pi-better-goal": "0.1.22",
50
- "pi-better-sandbox": "0.1.0",
50
+ "pi-better-sandbox": "0.2.0",
51
51
  "pi-better-subagents": "0.1.22"
52
52
  },
53
53
  "bundledDependencies": [