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 +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/sandbox.ts +10 -9
- 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/package.json +4 -4
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
|
|
|
@@ -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
|
}
|
|
@@ -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");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension bundle for
|
|
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.
|
|
48
|
+
"pi-better-background-tasks": "0.2.7",
|
|
49
49
|
"pi-better-goal": "0.1.22",
|
|
50
|
-
"pi-better-sandbox": "0.
|
|
50
|
+
"pi-better-sandbox": "0.2.0",
|
|
51
51
|
"pi-better-subagents": "0.1.22"
|
|
52
52
|
},
|
|
53
53
|
"bundledDependencies": [
|