pi-better-harness 0.1.27 → 0.2.1
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 +9 -4
- package/lib/cli.mjs +2 -2
- package/node_modules/pi-better-background-tasks/README.md +6 -4
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/process.ts +185 -15
- package/node_modules/pi-better-background-tasks/src/runtime.ts +14 -1
- package/node_modules/pi-better-background-tasks/src/sandbox.ts +10 -9
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +10 -4
- package/node_modules/pi-better-sandbox/README.md +27 -20
- package/node_modules/pi-better-sandbox/commands.ts +75 -10
- package/node_modules/pi-better-sandbox/events.ts +15 -3
- package/node_modules/pi-better-sandbox/index.ts +35 -7
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/preferences.ts +93 -0
- package/node_modules/pi-better-sandbox/state.ts +34 -22
- package/node_modules/pi-better-sandbox/status.ts +4 -0
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +10 -4
- package/package.json +5 -5
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:
|
|
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
|
|
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
|
|
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`,
|
|
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
|
|
11
|
-
//
|
|
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.
|
|
31
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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,10 +1,47 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { appendFileSync, closeSync, mkdirSync, openSync, writeSync } from "node:fs";
|
|
3
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import type { ChildProcess } from "node:child_process";
|
|
5
5
|
import type { CommandResult, CommandSpec } from "./types.js";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** Known Git for Windows locations; `bash -lc` needs a real bash, not the WSL shim. */
|
|
8
|
+
const WINDOWS_BASH_CANDIDATES = [
|
|
9
|
+
"C:\\Program Files\\Git\\bin\\bash.exe",
|
|
10
|
+
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
|
11
|
+
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the shell used for `command` specs.
|
|
16
|
+
*
|
|
17
|
+
* POSIX keeps `/bin/bash`. Windows has no `/bin/bash`, and the `bash.exe` found
|
|
18
|
+
* on PATH is usually the WSL launcher in System32 or the WindowsApps alias,
|
|
19
|
+
* either of which would run the command inside WSL instead of Windows. Prefer
|
|
20
|
+
* an explicit override, then Git for Windows, then a non-WSL `bash.exe` on PATH.
|
|
21
|
+
*
|
|
22
|
+
* Resolved lazily at spawn time, not module load: env-injection extensions
|
|
23
|
+
* (e.g. pi-env) may apply settings.json `env` values after this module is
|
|
24
|
+
* evaluated, and those overrides must still take effect.
|
|
25
|
+
*
|
|
26
|
+
* Exposed for tests and reuse.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveDefaultShell(): string {
|
|
29
|
+
const fromEnv = process.env.PI_BETTER_BACKGROUND_TASKS_SHELL;
|
|
30
|
+
if (fromEnv) return fromEnv;
|
|
31
|
+
if (process.platform !== "win32") return "/bin/bash";
|
|
32
|
+
for (const candidate of WINDOWS_BASH_CANDIDATES) {
|
|
33
|
+
if (existsSync(candidate)) return candidate;
|
|
34
|
+
}
|
|
35
|
+
for (const dir of (process.env.PATH ?? "").split(";")) {
|
|
36
|
+
const trimmed = dir.trim();
|
|
37
|
+
if (!trimmed || /(^|[\\/])(system32|windowsapps)([\\/]|$)/i.test(trimmed)) continue;
|
|
38
|
+
const candidate = join(trimmed, "bash.exe");
|
|
39
|
+
if (existsSync(candidate)) return candidate;
|
|
40
|
+
}
|
|
41
|
+
// Nothing usable found: keep the POSIX default so the failure surfaces as a
|
|
42
|
+
// logged spawn error for the task instead of crashing the whole host process.
|
|
43
|
+
return "/bin/bash";
|
|
44
|
+
}
|
|
8
45
|
|
|
9
46
|
/** How long a timed-out process group has to exit on SIGTERM before SIGKILL. */
|
|
10
47
|
const TERMINATION_GRACE_MS = 2_000;
|
|
@@ -26,15 +63,98 @@ export function validateCommandSpec(spec: CommandSpec): void {
|
|
|
26
63
|
}
|
|
27
64
|
}
|
|
28
65
|
|
|
66
|
+
/** Convert a Windows path to the `/c/...` form MSYS bash resolves in redirections. Exposed for tests and reuse. */
|
|
67
|
+
export function toMsysPath(path: string): string {
|
|
68
|
+
const forward = path.replace(/\\/g, "/");
|
|
69
|
+
const drive = /^([A-Za-z]):(\/.+)$/.exec(forward);
|
|
70
|
+
return drive ? `/${drive[1].toLowerCase()}${drive[2]}` : forward;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Single-quote a value for safe literal use in a bash script line. Exposed for tests and reuse. */
|
|
74
|
+
export function bashSingleQuote(value: string): string {
|
|
75
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* On Windows, numeric fds above 2 are unusable as child stdio: Node spawns the
|
|
80
|
+
* process, but its output handles end up broken, every write fails, and shell
|
|
81
|
+
* tasks exit 1 having produced nothing. (POSIX inherits the fd normally.)
|
|
82
|
+
*
|
|
83
|
+
* Instead of handing the child a log fd, the shell opens and redirects into the
|
|
84
|
+
* log itself. Output stays durable — written by the detached task directly, so
|
|
85
|
+
* logging continues after pi exits — and the child runs with no inherited
|
|
86
|
+
* stdio. Raw argv specs get a bash trampoline (`exec`) that performs the same
|
|
87
|
+
* redirect before replacing itself with the target program.
|
|
88
|
+
*
|
|
89
|
+
* Exposed for tests and reuse.
|
|
90
|
+
*/
|
|
91
|
+
export function withWindowsLogRedirect(spec: CommandSpec, logPath: string): CommandSpec {
|
|
92
|
+
const redirectLine = `exec >> ${bashSingleQuote(toMsysPath(logPath))} 2>&1`;
|
|
93
|
+
if (spec.shell === false) {
|
|
94
|
+
const argvText = spec.argv!.map((arg) => bashSingleQuote(String(arg))).join(" ");
|
|
95
|
+
return {
|
|
96
|
+
...spec,
|
|
97
|
+
shell: true,
|
|
98
|
+
// The MSYS2 runtime rewrites POSIX-looking argv (e.g. `/c`, `/opt/x.sh`)
|
|
99
|
+
// when exec'ing native Windows binaries. Node spawn passed argv verbatim,
|
|
100
|
+
// so conversion is disabled to keep raw-argv semantics unchanged. The
|
|
101
|
+
// redirect target is unaffected: bash resolves it itself, already in
|
|
102
|
+
// `/c/...` form.
|
|
103
|
+
command: `${redirectLine}\nexport MSYS2_ARG_CONV_EXCL='*'\nexec ${argvText}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { ...spec, command: `${redirectLine}\n${spec.command}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
29
109
|
export function spawnCommand(spec: CommandSpec, logPath: string, detached: boolean): SpawnedProcess {
|
|
30
110
|
validateCommandSpec(spec);
|
|
31
111
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
112
|
+
const windows = process.platform === "win32";
|
|
113
|
+
const launchSpec = windows ? withWindowsLogRedirect(spec, logPath) : spec;
|
|
114
|
+
let fd: number | undefined;
|
|
115
|
+
let stdio: SpawnStdio;
|
|
116
|
+
if (windows) {
|
|
117
|
+
stdio = ["ignore", "ignore", "ignore"];
|
|
118
|
+
} else {
|
|
119
|
+
fd = openSync(logPath, "a");
|
|
120
|
+
stdio = ["ignore", fd, fd];
|
|
121
|
+
}
|
|
122
|
+
const child = spawnArgs(launchSpec, detached, stdio);
|
|
123
|
+
const marker = `\n--- spawn ${new Date().toISOString()} pid=${child.pid ?? "unknown"} ---\n`;
|
|
124
|
+
try {
|
|
125
|
+
if (fd !== undefined) {
|
|
126
|
+
writeSync(fd, marker);
|
|
127
|
+
} else {
|
|
128
|
+
// The detached child is already running; a throw here would orphan it
|
|
129
|
+
// with no task metadata, so the marker write is best effort.
|
|
130
|
+
appendFileSync(logPath, marker);
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Log unavailable; the runtime close handler still records the failure.
|
|
134
|
+
} finally {
|
|
135
|
+
if (fd !== undefined) {
|
|
136
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Spawn failures (ENOENT, bad cwd, permission denied) surface as an 'error'
|
|
140
|
+
// event. With no listener Node turns it into an uncaughtException that takes
|
|
141
|
+
// down the whole host process; log it here and let the runtime's 'close'
|
|
142
|
+
// handler finalize the task as failed.
|
|
143
|
+
child.on("error", (error) => {
|
|
144
|
+
try {
|
|
145
|
+
const code = (error as NodeJS.ErrnoException).code ?? "unknown";
|
|
146
|
+
appendFileSync(logPath, `\n--- spawn error ${new Date().toISOString()} code=${code} message=${error.message} ---\n`);
|
|
147
|
+
} catch {
|
|
148
|
+
// Log unavailable; the runtime close handler still records the failure.
|
|
149
|
+
}
|
|
150
|
+
});
|
|
36
151
|
child.on("close", (code, signal) => {
|
|
37
|
-
|
|
152
|
+
try {
|
|
153
|
+
appendFileSync(logPath, `\n--- exit ${new Date().toISOString()} code=${code ?? "null"} signal=${signal ?? "null"} ---\n`);
|
|
154
|
+
} catch {
|
|
155
|
+
// Log unavailable (swept tmp dir, ACL change): a throw here would crash
|
|
156
|
+
// the host; the runtime already finalized the task from meta.
|
|
157
|
+
}
|
|
38
158
|
});
|
|
39
159
|
return { child, pgid: detached && child.pid ? child.pid : undefined };
|
|
40
160
|
}
|
|
@@ -80,18 +200,32 @@ export function runCommandOnce(
|
|
|
80
200
|
if (child.pid === undefined) return;
|
|
81
201
|
try {
|
|
82
202
|
stopProcessGroup(child.pid, undefined, signal);
|
|
83
|
-
} catch {
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (process.platform === "win32") throw error;
|
|
84
205
|
// Nothing left in the group: the tree is already gone.
|
|
85
206
|
}
|
|
86
207
|
};
|
|
87
208
|
let escalation: NodeJS.Timeout | undefined;
|
|
88
209
|
const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
89
210
|
timedOut = true;
|
|
90
|
-
|
|
211
|
+
try {
|
|
212
|
+
signalGroup("SIGTERM");
|
|
213
|
+
} catch (error) {
|
|
214
|
+
settle();
|
|
215
|
+
reject(error);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
91
218
|
// SIGTERM is a request. Whatever still holds the output pipes after the
|
|
92
219
|
// grace period is exactly what would keep this promise pending, so the
|
|
93
220
|
// group is killed outright rather than waited on.
|
|
94
|
-
escalation = setTimeout(() =>
|
|
221
|
+
escalation = setTimeout(() => {
|
|
222
|
+
try {
|
|
223
|
+
signalGroup("SIGKILL");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
settle();
|
|
226
|
+
reject(error);
|
|
227
|
+
}
|
|
228
|
+
}, TERMINATION_GRACE_MS);
|
|
95
229
|
escalation.unref();
|
|
96
230
|
}, Math.max(1, timeoutMs));
|
|
97
231
|
timeout?.unref();
|
|
@@ -118,11 +252,41 @@ export function runCommandOnce(
|
|
|
118
252
|
});
|
|
119
253
|
}
|
|
120
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Stop a task's process tree.
|
|
257
|
+
*
|
|
258
|
+
* POSIX signals the process group (`-target`), falling back to the direct pid
|
|
259
|
+
* when the group is already gone. Windows has no process groups in libuv, and
|
|
260
|
+
* `process.kill(pid)` would only terminate the spawned `bash.exe` while real
|
|
261
|
+
* work survives in grandchildren — so the tree is terminated with
|
|
262
|
+
* `taskkill /T /F` instead. Windows has no graceful signal delivery, so the
|
|
263
|
+
* requested signal is informational there.
|
|
264
|
+
*/
|
|
121
265
|
export function stopProcessGroup(
|
|
122
266
|
pid: number,
|
|
123
267
|
pgid?: number,
|
|
124
268
|
signal: NodeJS.Signals = "SIGTERM",
|
|
125
269
|
): void {
|
|
270
|
+
if (process.platform === "win32") {
|
|
271
|
+
const result = spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], {
|
|
272
|
+
encoding: "utf8",
|
|
273
|
+
windowsHide: true,
|
|
274
|
+
});
|
|
275
|
+
if (result.error) {
|
|
276
|
+
const code = (result.error as NodeJS.ErrnoException).code;
|
|
277
|
+
throw new Error(`taskkill could not start for PID ${pid}${code ? ` (${code})` : ""}: ${result.error.message}`, {
|
|
278
|
+
cause: result.error,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (result.status === 0) return;
|
|
282
|
+
// A child can exit between the caller's liveness check and taskkill. That
|
|
283
|
+
// race is success; any still-live PID means the tree was not terminated.
|
|
284
|
+
if (!processExists(pid)) return;
|
|
285
|
+
const detail = String(result.stderr ?? result.stdout ?? "").replace(/\s+/g, " ").trim();
|
|
286
|
+
throw new Error(
|
|
287
|
+
`taskkill failed with exit ${result.status ?? "unknown"} for PID ${pid}${detail ? `: ${detail}` : ""}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
126
290
|
const target = pgid ?? pid;
|
|
127
291
|
try {
|
|
128
292
|
process.kill(-target, signal);
|
|
@@ -153,13 +317,16 @@ export function commandExecution(spec: CommandSpec): { execPath: string; execArg
|
|
|
153
317
|
const [command, ...args] = spec.argv!;
|
|
154
318
|
return { execPath: command!, execArgs: args };
|
|
155
319
|
}
|
|
156
|
-
return { execPath:
|
|
320
|
+
return { execPath: resolveDefaultShell(), execArgs: ["-lc", spec.command!] };
|
|
157
321
|
}
|
|
158
322
|
|
|
323
|
+
/** Stdio for a spawned task: stdin is always ignored; stdout/stderr are piped (collected) or ignored (redirected into the log by the child itself). */
|
|
324
|
+
type SpawnStdio = ["ignore", "pipe" | "ignore" | number, "pipe" | "ignore" | number];
|
|
325
|
+
|
|
159
326
|
function spawnArgs(
|
|
160
327
|
spec: CommandSpec,
|
|
161
328
|
detached: boolean,
|
|
162
|
-
stdio:
|
|
329
|
+
stdio: SpawnStdio,
|
|
163
330
|
): ChildProcess {
|
|
164
331
|
const env = { ...process.env, ...spec.env };
|
|
165
332
|
const { execPath, execArgs } = commandExecution(spec);
|
|
@@ -168,5 +335,8 @@ function spawnArgs(
|
|
|
168
335
|
env,
|
|
169
336
|
detached,
|
|
170
337
|
stdio,
|
|
338
|
+
// A detached Windows child gets its own console window unless hidden; these
|
|
339
|
+
// tasks write to log files and must not flash terminals.
|
|
340
|
+
windowsHide: process.platform === "win32",
|
|
171
341
|
});
|
|
172
|
-
}
|
|
342
|
+
}
|
|
@@ -490,7 +490,13 @@ export async function stopTask(
|
|
|
490
490
|
try {
|
|
491
491
|
stopProcessGroup(meta.pid, meta.pgid);
|
|
492
492
|
} catch (error) {
|
|
493
|
+
meta.stopRequestedAt = undefined;
|
|
493
494
|
meta.error = error instanceof Error ? error.message : String(error);
|
|
495
|
+
writeMeta(meta);
|
|
496
|
+
if (meta.deadlineAt && meta.deadlineAt > Date.now()) {
|
|
497
|
+
scheduleProcessTimeout(pi, id, meta.deadlineAt, getActiveSession);
|
|
498
|
+
}
|
|
499
|
+
return meta;
|
|
494
500
|
}
|
|
495
501
|
}
|
|
496
502
|
|
|
@@ -726,7 +732,14 @@ async function finalizeProcessTimeout(
|
|
|
726
732
|
}
|
|
727
733
|
} else {
|
|
728
734
|
if (meta.pid) {
|
|
729
|
-
try {
|
|
735
|
+
try {
|
|
736
|
+
stopProcessGroup(meta.pid, meta.pgid);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
reason = `timeout; could not terminate local process tree: ${readableError(error)}`;
|
|
739
|
+
meta.error = reason;
|
|
740
|
+
writeMeta(meta);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
730
743
|
}
|
|
731
744
|
if (meta.remote?.session === "direct") {
|
|
732
745
|
reason = "timeout; terminated local SSH client, but the remote process may still be running";
|
|
@@ -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. **
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
187
|
-
*
|
|
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
|
}
|
|
@@ -152,7 +152,12 @@ export function unregisterBackgroundWorkProvider(id: string): void {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boolean {
|
|
155
|
-
|
|
155
|
+
if (!ctx) return false;
|
|
156
|
+
try {
|
|
157
|
+
return Boolean(ctx.mode === "tui" && ctx.hasUI === true && ctx.ui);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
export function navigatorFooterHint(count: number): string | null {
|
|
@@ -191,8 +196,10 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
191
196
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
192
197
|
s.dispose = undefined;
|
|
193
198
|
stopMainListWidget();
|
|
194
|
-
const
|
|
195
|
-
|
|
199
|
+
const activeCtx = ctx ?? s.uiCtx;
|
|
200
|
+
s.uiCtx = undefined;
|
|
201
|
+
if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
|
|
202
|
+
const ui = activeCtx.ui;
|
|
196
203
|
try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
|
|
197
204
|
try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
|
|
198
205
|
try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
@@ -207,7 +214,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
207
214
|
s.detailOverlayRows = undefined;
|
|
208
215
|
s.mainListSelectedId = undefined;
|
|
209
216
|
s.mainListFocused = false;
|
|
210
|
-
if (ctx && s.uiCtx === ctx) s.uiCtx = undefined;
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-better-sandbox
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
same
|
|
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
|
|
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 ·
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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`
|
|
89
|
-
there is no interactive UI. There is no tool for changing
|
|
90
|
-
rules, so the model
|
|
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
|
|
162
|
-
resume, fork,
|
|
163
|
-
|
|
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
|
|
171
|
-
and file mutations are **blocked** rather than run
|
|
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,
|
|
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.
|
|
56
|
-
"
|
|
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
|
|
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
|
|
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
|
|
208
|
-
?
|
|
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
|
-
/**
|
|
25
|
-
|
|
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({
|
|
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 -
|
|
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
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
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({
|
|
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,
|
|
@@ -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,
|
|
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
|
-
*
|
|
10
|
-
* startup, new, resume, fork, reload — calls `beginSession`
|
|
11
|
-
*
|
|
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
|
-
#
|
|
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
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
|
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:
|
|
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
|
|
244
|
-
*
|
|
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");
|
|
@@ -152,7 +152,12 @@ export function unregisterBackgroundWorkProvider(id: string): void {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boolean {
|
|
155
|
-
|
|
155
|
+
if (!ctx) return false;
|
|
156
|
+
try {
|
|
157
|
+
return Boolean(ctx.mode === "tui" && ctx.hasUI === true && ctx.ui);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
export function navigatorFooterHint(count: number): string | null {
|
|
@@ -191,8 +196,10 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
191
196
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
192
197
|
s.dispose = undefined;
|
|
193
198
|
stopMainListWidget();
|
|
194
|
-
const
|
|
195
|
-
|
|
199
|
+
const activeCtx = ctx ?? s.uiCtx;
|
|
200
|
+
s.uiCtx = undefined;
|
|
201
|
+
if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
|
|
202
|
+
const ui = activeCtx.ui;
|
|
196
203
|
try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
|
|
197
204
|
try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
|
|
198
205
|
try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
@@ -207,7 +214,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
207
214
|
s.detailOverlayRows = undefined;
|
|
208
215
|
s.mainListSelectedId = undefined;
|
|
209
216
|
s.mainListFocused = false;
|
|
210
|
-
if (ctx && s.uiCtx === ctx) s.uiCtx = undefined;
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Pi extension bundle for
|
|
3
|
+
"version": "0.2.1",
|
|
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,10 +45,10 @@
|
|
|
45
45
|
"test": "node --test test/*.test.mjs"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"pi-better-background-tasks": "0.2.
|
|
48
|
+
"pi-better-background-tasks": "0.2.8",
|
|
49
49
|
"pi-better-goal": "0.1.22",
|
|
50
|
-
"pi-better-sandbox": "0.
|
|
51
|
-
"pi-better-subagents": "0.1.
|
|
50
|
+
"pi-better-sandbox": "0.2.0",
|
|
51
|
+
"pi-better-subagents": "0.1.23"
|
|
52
52
|
},
|
|
53
53
|
"bundledDependencies": [
|
|
54
54
|
"pi-better-background-tasks",
|