pi-better-sandbox 0.1.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/shell.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Foreground shell confinement.
3
+ *
4
+ * Pi's own bash backend keeps doing all the process work — streaming, timeout,
5
+ * cancellation, process-tree termination, session environment — and this module
6
+ * changes exactly one thing: the command handed to it now `exec`s the sandbox
7
+ * wrapper around the same shell running the same command.
8
+ *
9
+ * The wrapper argv is built by `sandbox-core` and passed through single-quote
10
+ * escaping, so the user's command text is never re-tokenized: it travels as one
11
+ * argv element to the inner shell, exactly as Pi would have passed it.
12
+ */
13
+
14
+ import { createLocalBashOperations, getShellConfig } from "@earendil-works/pi-coding-agent";
15
+ import type { BashOperations } from "@earendil-works/pi-coding-agent";
16
+
17
+ import {
18
+ maybeBuildSandboxCommand,
19
+ type SandboxSeams,
20
+ sandboxUnavailableMessage,
21
+ } from "./shared-sandbox-core.ts";
22
+ import { FOREGROUND_SANDBOX_REMEDY } from "./state.ts";
23
+ import type { ForegroundSandboxController, ForegroundSandboxLaunchPlan } from "./state.ts";
24
+
25
+ /**
26
+ * Quote one argv element for a POSIX shell.
27
+ *
28
+ * Single quotes suppress every expansion, so the only character needing care is
29
+ * the single quote itself. This is a lossless transport of one argv element,
30
+ * not an attempt to parse the command.
31
+ */
32
+ export function quoteForPosixShell(value: string): string {
33
+ return `'${value.replaceAll("'", `'\\''`)}'`;
34
+ }
35
+
36
+ export type ForegroundShellSeams = SandboxSeams & {
37
+ /** Explicit shell path from Pi's settings, when the user configured one. */
38
+ shellPath?: () => string | undefined;
39
+ };
40
+
41
+ /**
42
+ * Build the command Pi's local shell backend should run so that `command`
43
+ * executes inside the sandbox.
44
+ *
45
+ * `exec` replaces the outer shell with the wrapper, so the process Pi tracks
46
+ * and kills is the sandboxed one.
47
+ */
48
+ export function buildSandboxedShellCommand(
49
+ command: string,
50
+ plan: Extract<ForegroundSandboxLaunchPlan, { confined: true }>,
51
+ seams: ForegroundShellSeams = {},
52
+ ): string {
53
+ const shellConfig = getShellConfig(seams.shellPath?.());
54
+ if (shellConfig.commandTransport === "stdin") {
55
+ throw new Error(
56
+ `Foreground sandbox cannot wrap ${shellConfig.shell}: it accepts commands on stdin only, which no supported sandbox backend can wrap. ${sandboxUnavailableMessage(seams)} ${FOREGROUND_SANDBOX_REMEDY}`,
57
+ );
58
+ }
59
+
60
+ const sandboxCommand = maybeBuildSandboxCommand(
61
+ {
62
+ profilePath: plan.profilePath,
63
+ policy: plan.policy,
64
+ execPath: shellConfig.shell,
65
+ execArgs: [...shellConfig.args, command],
66
+ },
67
+ // Fail closed: an enabled foreground sandbox with no backend must block
68
+ // the operation, never fall through to an unconfined child.
69
+ { sandboxEnabled: true, explicitSandbox: true, remedy: FOREGROUND_SANDBOX_REMEDY },
70
+ seams,
71
+ );
72
+ if (!sandboxCommand) {
73
+ throw new Error(`${sandboxUnavailableMessage(seams)} ${FOREGROUND_SANDBOX_REMEDY}`);
74
+ }
75
+
76
+ // `exec` stays unquoted so the outer shell replaces itself with the wrapper:
77
+ // the process pi tracks, signals, and kills is the sandboxed one.
78
+ const argv = [sandboxCommand.file, ...sandboxCommand.fileArgs].map(quoteForPosixShell);
79
+ return `exec ${argv.join(" ")}`;
80
+ }
81
+
82
+ export type SandboxedBashOperationsOptions = ForegroundShellSeams & {
83
+ /**
84
+ * Pi's local shell backend. Defaults to `createLocalBashOperations()`, which
85
+ * is what preserves every process contract the built-in bash tool relies on.
86
+ */
87
+ localOperations?: BashOperations;
88
+ };
89
+
90
+ /**
91
+ * Pluggable bash operations that confine every command they run.
92
+ *
93
+ * The launch decision is taken per command, so `/sandbox on` and `/sandbox off`
94
+ * affect operations started after the toggle while already-running commands
95
+ * keep the policy they launched with.
96
+ */
97
+ export function createSandboxedBashOperations(
98
+ controller: ForegroundSandboxController,
99
+ options: SandboxedBashOperationsOptions = {},
100
+ ): BashOperations {
101
+ // Pi's shell setting is only readable once a session exists, which is after
102
+ // this factory runs, so the local backend is built on first use and reused
103
+ // until the resolved shell path changes.
104
+ let cachedShellPath: string | undefined;
105
+ let cachedLocal: BashOperations | undefined;
106
+ const localOperations = (): BashOperations => {
107
+ if (options.localOperations) return options.localOperations;
108
+ const shellPath = options.shellPath?.();
109
+ if (!cachedLocal || cachedShellPath !== shellPath) {
110
+ cachedShellPath = shellPath;
111
+ cachedLocal = createLocalBashOperations(shellPath === undefined ? {} : { shellPath });
112
+ }
113
+ return cachedLocal;
114
+ };
115
+
116
+ return {
117
+ async exec(command, cwd, execOptions) {
118
+ const local = localOperations();
119
+ // Rejects when the sandbox is enabled but cannot be applied. The
120
+ // caller never retries the command unconfined.
121
+ const plan = controller.requireLaunchPlan();
122
+ if (!plan.confined) return local.exec(command, cwd, execOptions);
123
+ return local.exec(buildSandboxedShellCommand(command, plan, options), cwd, execOptions);
124
+ },
125
+ };
126
+ }
package/state.ts ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Session-local foreground sandbox state.
3
+ *
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
6
+ * the *effective* status derived from live runtime evidence (which backend this
7
+ * platform actually resolves, not what the package intended).
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.
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { homedir, tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import {
20
+ canonicalizePath,
21
+ describeSandboxSupport,
22
+ type SandboxBackendId,
23
+ type SandboxWritePolicy,
24
+ } from "./shared-sandbox-core.ts";
25
+ import {
26
+ describeUnsafeProjectRoot,
27
+ PACKAGED_DENY_WRITE_TEMPLATES,
28
+ type PolicySeams,
29
+ resolveDenyWriteTemplates,
30
+ } from "./policy.ts";
31
+
32
+ /**
33
+ * What the foreground sandbox is actually doing right now.
34
+ *
35
+ * - `enabled` - a backend is resolved and protected operations are wrapped.
36
+ * - `disabled` - a human turned it off for this session.
37
+ * - `unavailable` - this platform resolves no backend; protected operations are blocked.
38
+ * - `failed` - protection cannot be applied here (no session yet, or an
39
+ * unsafe launch root); protected operations are blocked.
40
+ */
41
+ export type ForegroundSandboxState = "enabled" | "disabled" | "unavailable" | "failed";
42
+
43
+ /**
44
+ * The immutable effective-policy snapshot published to first-party consumers
45
+ * and rendered by `/sandbox` and the footer. Everything in it is evidence, not
46
+ * intent: `backend` and `executable` come from backend resolution, not config.
47
+ */
48
+ export type ForegroundSandboxStatus = {
49
+ readonly state: ForegroundSandboxState;
50
+ /** Canonical launch directory, or undefined before the first session start. */
51
+ readonly projectRoot: string | undefined;
52
+ /** The only writable subtree while `state` is `enabled`. */
53
+ readonly writableRoot: string | undefined;
54
+ /** Canonical paths that stay non-writable inside the writable root. */
55
+ readonly denyWrite: readonly string[];
56
+ readonly platform: string;
57
+ readonly backend: SandboxBackendId | undefined;
58
+ readonly executable: string | undefined;
59
+ /** Reads are never restricted by this sandbox. */
60
+ readonly readPolicy: "unrestricted";
61
+ /** Network is never restricted by this sandbox. */
62
+ readonly networkPolicy: "unrestricted";
63
+ /** Human-readable evidence for why `state` is what it is. */
64
+ readonly reason: string;
65
+ };
66
+
67
+ /** A launch decision for one protected operation. */
68
+ export type ForegroundSandboxLaunchPlan =
69
+ | { readonly confined: false }
70
+ | {
71
+ readonly confined: true;
72
+ readonly policy: SandboxWritePolicy;
73
+ readonly profilePath: string;
74
+ };
75
+
76
+ /** Thrown when a protected operation must be blocked instead of run unconfined. */
77
+ export class ForegroundSandboxBlockedError extends Error {
78
+ readonly status: ForegroundSandboxStatus;
79
+
80
+ constructor(status: ForegroundSandboxStatus) {
81
+ super(
82
+ `Foreground sandbox is ${status.state}; this command was blocked rather than run unconfined. ${status.reason}`,
83
+ );
84
+ this.name = "ForegroundSandboxBlockedError";
85
+ this.status = status;
86
+ }
87
+ }
88
+
89
+ export type ForegroundSandboxSeams = PolicySeams & {
90
+ /** Creates the directory generated sandbox profiles are written into. */
91
+ createProfileDir?: () => string;
92
+ };
93
+
94
+ /**
95
+ * What an operator can do about a missing backend on a foreground surface.
96
+ *
97
+ * `sandbox:false` is the subagent tool's opt-out and means nothing here; the
98
+ * only lever on a session is the slash command.
99
+ */
100
+ export const FOREGROUND_SANDBOX_REMEDY =
101
+ "Run unconfined on purpose with /sandbox off, or work in a session that has a backend.";
102
+
103
+ const NO_SESSION_REASON =
104
+ "No session has started yet, so no canonical project root has been captured.";
105
+
106
+ export class ForegroundSandboxController {
107
+ readonly #seams: ForegroundSandboxSeams;
108
+ #projectRoot: string | undefined;
109
+ #unsafeRootReason: string | undefined;
110
+ #denyWrite: readonly string[] = [];
111
+ #denyTemplates: readonly string[] = PACKAGED_DENY_WRITE_TEMPLATES;
112
+ #userEnabled = true;
113
+ #profileDir: string | undefined;
114
+
115
+ constructor(seams: ForegroundSandboxSeams = {}) {
116
+ this.#seams = seams;
117
+ }
118
+
119
+ /**
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.
124
+ */
125
+ beginSession(cwd: string): ForegroundSandboxStatus {
126
+ const projectRoot = canonicalizePath(cwd, this.#seams);
127
+ this.#projectRoot = projectRoot;
128
+ this.#unsafeRootReason = describeUnsafeProjectRoot(projectRoot, this.#seams);
129
+ this.#denyWrite = Object.freeze(
130
+ this.#unsafeRootReason
131
+ ? []
132
+ : resolveDenyWriteTemplates(this.#denyTemplates, projectRoot, this.#seams),
133
+ );
134
+ this.#userEnabled = true;
135
+ return this.status();
136
+ }
137
+
138
+ /** Re-enable protection for operations launched from now on. */
139
+ enable(): ForegroundSandboxStatus {
140
+ this.#userEnabled = true;
141
+ return this.status();
142
+ }
143
+
144
+ /** Turn protection off for this session only. Never persisted. */
145
+ disable(): ForegroundSandboxStatus {
146
+ this.#userEnabled = false;
147
+ return this.status();
148
+ }
149
+
150
+ /** Whether a human has left protection switched on. */
151
+ isUserEnabled(): boolean {
152
+ return this.#userEnabled;
153
+ }
154
+
155
+ /** The deny-write templates currently in force (packaged defaults for now). */
156
+ denyWriteTemplates(): readonly string[] {
157
+ return this.#denyTemplates;
158
+ }
159
+
160
+ /**
161
+ * Replace the deny-write template set and recompile it against the current
162
+ * project root. Persistence and validation of user overrides belong to the
163
+ * deny-rule unit; this is the single place a new set takes effect.
164
+ */
165
+ setDenyWriteTemplates(templates: readonly string[]): ForegroundSandboxStatus {
166
+ this.#denyTemplates = Object.freeze([...templates]);
167
+ if (this.#projectRoot !== undefined && this.#unsafeRootReason === undefined) {
168
+ this.#denyWrite = Object.freeze(
169
+ resolveDenyWriteTemplates(this.#denyTemplates, this.#projectRoot, this.#seams),
170
+ );
171
+ }
172
+ return this.status();
173
+ }
174
+
175
+ /** The current effective status, recomputed from live runtime evidence. */
176
+ status(): ForegroundSandboxStatus {
177
+ const support = describeSandboxSupport(this.#seams);
178
+ const base = {
179
+ projectRoot: this.#projectRoot,
180
+ denyWrite: this.#denyWrite,
181
+ platform: support.platform,
182
+ readPolicy: "unrestricted",
183
+ networkPolicy: "unrestricted",
184
+ } as const;
185
+
186
+ if (this.#projectRoot === undefined) {
187
+ return Object.freeze({
188
+ ...base,
189
+ state: "failed",
190
+ writableRoot: undefined,
191
+ backend: undefined,
192
+ executable: undefined,
193
+ reason: NO_SESSION_REASON,
194
+ });
195
+ }
196
+
197
+ if (!this.#userEnabled) {
198
+ return Object.freeze({
199
+ ...base,
200
+ state: "disabled",
201
+ writableRoot: undefined,
202
+ backend: support.supported ? support.backend : undefined,
203
+ executable: support.supported ? support.executable : undefined,
204
+ reason: "A human turned the foreground sandbox off for this session with /sandbox off.",
205
+ });
206
+ }
207
+
208
+ if (this.#unsafeRootReason !== undefined) {
209
+ return Object.freeze({
210
+ ...base,
211
+ state: "failed",
212
+ writableRoot: undefined,
213
+ backend: support.supported ? support.backend : undefined,
214
+ executable: support.supported ? support.executable : undefined,
215
+ reason: this.#unsafeRootReason,
216
+ });
217
+ }
218
+
219
+ if (!support.supported) {
220
+ return Object.freeze({
221
+ ...base,
222
+ state: "unavailable",
223
+ writableRoot: undefined,
224
+ backend: undefined,
225
+ executable: undefined,
226
+ reason: `${support.reason} ${FOREGROUND_SANDBOX_REMEDY}`,
227
+ });
228
+ }
229
+
230
+ return Object.freeze({
231
+ ...base,
232
+ state: "enabled",
233
+ writableRoot: this.#projectRoot,
234
+ backend: support.backend,
235
+ executable: support.executable,
236
+ reason: `Writes are confined to ${this.#projectRoot} by ${support.backend} (${support.executable}).`,
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Decide how to launch one protected operation.
242
+ *
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.
246
+ */
247
+ requireLaunchPlan(): ForegroundSandboxLaunchPlan {
248
+ const status = this.status();
249
+ if (status.state === "disabled") return { confined: false };
250
+ if (status.state !== "enabled" || status.writableRoot === undefined) {
251
+ throw new ForegroundSandboxBlockedError(status);
252
+ }
253
+ // The generated profiles live in a temp directory, and both backends
254
+ // leave temp writable by design (pi's own tooling needs it). A confined
255
+ // command that could rewrite the profile the next one is launched under
256
+ // would be choosing its own confinement, so the directory holding them
257
+ // is denied to everything the sandbox launches. It stays out of the
258
+ // published status: this is the mechanism protecting itself, not a rule
259
+ // the operator wrote or can remove.
260
+ const profileDir = this.#profileDirectory();
261
+ const policy: SandboxWritePolicy = {
262
+ writableRoot: status.writableRoot,
263
+ denyWrite: Object.freeze([...status.denyWrite, profileDir]),
264
+ home: (this.#seams.home ?? homedir)(),
265
+ };
266
+ return { confined: true, policy, profilePath: this.#profilePathFor(policy) };
267
+ }
268
+
269
+ /** Drop the generated profiles this session created. */
270
+ dispose(): void {
271
+ if (this.#profileDir === undefined) return;
272
+ if (this.#seams.createProfileDir === undefined) {
273
+ rmSync(this.#profileDir, { recursive: true, force: true });
274
+ }
275
+ this.#profileDir = undefined;
276
+ }
277
+
278
+ /**
279
+ * Name the generated profile after the policy it encodes.
280
+ *
281
+ * Two commands launching concurrently under the same policy write identical
282
+ * bytes to the same path, and a policy change lands on a different path
283
+ * instead of rewriting the profile a starting command is about to read.
284
+ */
285
+ #profilePathFor(policy: SandboxWritePolicy): string {
286
+ const digest = createHash("sha256")
287
+ .update(JSON.stringify([policy.writableRoot, policy.denyWrite, policy.home]))
288
+ .digest("hex")
289
+ .slice(0, 16);
290
+ return join(this.#profileDirectory(), `foreground-${digest}.sb`);
291
+ }
292
+
293
+ #profileDirectory(): string {
294
+ this.#profileDir ??= (
295
+ this.#seams.createProfileDir ??
296
+ (() => mkdtempSync(join(tmpdir(), "pi-better-sandbox-")))
297
+ )();
298
+ return this.#profileDir;
299
+ }
300
+ }
package/status.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How the effective sandbox status is shown to a human.
3
+ *
4
+ * Both surfaces read the same snapshot, so the footer chip and the `/sandbox`
5
+ * report can never disagree, and neither can present configured intent as
6
+ * active kernel enforcement: every line below is rendered from evidence the
7
+ * controller collected at call time.
8
+ */
9
+
10
+ import { basename } from "node:path";
11
+
12
+ import type { ForegroundSandboxStatus } from "./state.ts";
13
+
14
+ /** Emphasis for one piece of status text, mapped to a theme colour by the caller. */
15
+ export type StatusTone = "accent" | "warning" | "error";
16
+
17
+ /** Applies terminal styling. Defaults to plain text so the formatters stay testable. */
18
+ export type StatusPainter = (tone: StatusTone, text: string) => string;
19
+
20
+ const plain: StatusPainter = (_tone, text) => text;
21
+
22
+ /** The tone the footer uses for a given state. */
23
+ export function footerTone(status: ForegroundSandboxStatus): StatusTone {
24
+ if (status.state === "enabled") return "accent";
25
+ if (status.state === "unavailable") return "warning";
26
+ return "error";
27
+ }
28
+
29
+ /**
30
+ * The compact footer chip.
31
+ *
32
+ * `sandbox · on · <project>` when protection is active; anything else is
33
+ * visually prominent, because the absence of protection is the surprising case.
34
+ */
35
+ export function formatFooterStatus(
36
+ status: ForegroundSandboxStatus,
37
+ paint: StatusPainter = plain,
38
+ ): string {
39
+ const tone = footerTone(status);
40
+ if (status.state === "enabled" && status.writableRoot !== undefined) {
41
+ return paint(tone, `sandbox · on · ${basename(status.writableRoot)}`);
42
+ }
43
+ if (status.state === "disabled") return paint(tone, "sandbox · OFF");
44
+ if (status.state === "unavailable") return paint(tone, "sandbox · UNAVAILABLE");
45
+ return paint(tone, "sandbox · FAILED");
46
+ }
47
+
48
+ /** The full `/sandbox` report. */
49
+ export function formatSandboxReport(status: ForegroundSandboxStatus): string {
50
+ const lines = [
51
+ `Foreground sandbox: ${status.state.toUpperCase()}`,
52
+ ` ${status.reason}`,
53
+ "",
54
+ `Project root: ${status.projectRoot ?? "(not captured yet)"}`,
55
+ `Writable root: ${status.writableRoot ?? "(none while not enabled)"}`,
56
+ `Reads: ${status.readPolicy} (every filesystem path)`,
57
+ `Network: ${status.networkPolicy}`,
58
+ `Platform: ${status.platform}`,
59
+ `Backend: ${status.backend ?? "(none resolved)"}`,
60
+ `Executable: ${status.executable ?? "(none resolved)"}`,
61
+ "Write-denied paths:",
62
+ ];
63
+ if (status.denyWrite.length === 0) lines.push(" (none)");
64
+ else for (const path of status.denyWrite) lines.push(` ${path}`);
65
+
66
+ lines.push(
67
+ "",
68
+ "Confined: the built-in bash, write, and edit tools, and user-entered ! / !! commands.",
69
+ "Not confined: pi's own process, pi.exec calls, and unrelated extension code.",
70
+ );
71
+ return lines.join("\n");
72
+ }