pi-better-sandbox 0.3.0 → 0.5.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 +87 -51
- package/commands.ts +7 -1
- package/files.ts +22 -9
- package/index.ts +71 -55
- package/package.json +6 -6
- package/permission-settings.ts +44 -0
- package/permissions-page.ts +177 -0
- package/permissions.ts +50 -0
- package/shared-sandbox-core.ts +309 -14
- package/shared-task-files.ts +246 -0
- package/shared-task-sandbox.ts +189 -0
- package/state.ts +86 -10
- package/status.ts +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-better-sandbox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sandbox permissions for Pi's foreground tools and detached subagents.
|
|
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,59 +9,79 @@ 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 or wrapper command.
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
12
|
+
no launcher or wrapper command. Main starts inactive; Subagents start confined.
|
|
13
|
+
Open `/sandbox` to change either column, or use `/sandbox on` for the current
|
|
14
|
+
Main session. **Save as defaults** persists both profiles.
|
|
18
15
|
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Write: the canonical launch directory and everything under it
|
|
22
|
-
Exceptions: .git/hooks, .env, .env.local
|
|
23
|
-
Network: unchanged
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
For shell commands the denial is done by the kernel, not by inspecting command
|
|
27
|
-
text: macOS uses Seatbelt (`sandbox-exec`) and Linux uses Bubblewrap (`bwrap`).
|
|
28
|
-
A crafted command cannot talk its way past it, because the write syscall itself
|
|
29
|
-
is refused.
|
|
30
|
-
|
|
31
|
-
`write` and `edit` never start a child process — they change files inside Pi's
|
|
32
|
-
own process — so there is no child to wrap. They are confined by a containment
|
|
33
|
-
check on the canonical target instead, run inside Pi's own file-mutation queue,
|
|
34
|
-
immediately before the filesystem call it guards. A refused mutation leaves
|
|
35
|
-
nothing behind on disk.
|
|
36
|
-
|
|
37
|
-
## What is confined, and what is not
|
|
16
|
+
```text
|
|
17
|
+
Sandbox permissions Main Subagents
|
|
38
18
|
|
|
39
|
-
|
|
40
|
-
stays readable and network behaviour is exactly what it was. This sandbox limits
|
|
41
|
-
writes, and nothing else.
|
|
19
|
+
Sandbox Off On
|
|
42
20
|
|
|
43
|
-
|
|
44
|
-
|
|
21
|
+
Project files - Read / write
|
|
22
|
+
Outside project - Read
|
|
23
|
+
Stored credentials - Read
|
|
24
|
+
Run commands & applications - On
|
|
25
|
+
Network access - On
|
|
45
26
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- Local [`pi-better-background-tasks`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-background-tasks#readme)
|
|
50
|
-
spawns and watches, which capture this policy at launch.
|
|
51
|
-
- [`pi-better-subagents`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-subagents#readme)
|
|
52
|
-
children, through the same shared mechanism.
|
|
27
|
+
↑↓ Select row ←→ Select column Space Change
|
|
28
|
+
Save as defaults
|
|
29
|
+
```
|
|
53
30
|
|
|
54
|
-
|
|
31
|
+
File permissions cycle through Off, Read, and Read / write. Other rows toggle
|
|
32
|
+
Off/On. Detail cells under an Off sandbox display a dimmed `-` and cannot be
|
|
33
|
+
changed; their values return when the sandbox is enabled again. Outside project
|
|
34
|
+
covers paths outside the assigned root without custom folder lists. Protected
|
|
35
|
+
paths remain write-denied regardless of the broad file settings.
|
|
36
|
+
|
|
37
|
+
**Stored credentials currently means known credential files.** It covers SSH,
|
|
38
|
+
AWS, GitHub CLI, Google Cloud CLI, Azure, Kubernetes, Docker, npm, netrc, Git
|
|
39
|
+
credentials, and Pi's file-based auth. These rules override ordinary file access.
|
|
40
|
+
OS vault services such as Keychain and Secret Service, and tokens inherited in
|
|
41
|
+
environment variables, are excluded. Read / write may be needed by a CLI that
|
|
42
|
+
refreshes a token or updates its credential database.
|
|
43
|
+
|
|
44
|
+
File and shell operations use the kernel: macOS uses Seatbelt (`sandbox-exec`)
|
|
45
|
+
and Linux uses Bubblewrap (`bwrap`). `read`, `write`, and `edit` keep Pi's normal
|
|
46
|
+
tool behavior and mutation queues, while a fixed worker performs filesystem
|
|
47
|
+
syscalls under the selected policy. Canonical checks explain denials; kernel
|
|
48
|
+
enforcement also protects against a symlink changing between checking and use.
|
|
49
|
+
The confined file worker rejects files over 8 MiB instead of silently truncating
|
|
50
|
+
them; larger-file processing can use a confined command when commands are On.
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
- `pi.exec` calls made by extensions.
|
|
58
|
-
- Unrelated third-party extension code.
|
|
59
|
-
- Another first-party surface's control plane. Each surface denies its own —
|
|
60
|
-
the files naming what it will run next — but not every other surface's, so
|
|
61
|
-
confinement is per surface rather than global.
|
|
52
|
+
## What is confined, and what is not
|
|
62
53
|
|
|
63
|
-
|
|
64
|
-
|
|
54
|
+
Pi is the trusted runtime. It can lock configuration/authentication files,
|
|
55
|
+
connect to its provider, and persist sessions. Main and Subagents permissions
|
|
56
|
+
apply to task operations. Subagents can start with task Network access or Run
|
|
57
|
+
commands & applications Off. The fixed file worker remains available according
|
|
58
|
+
to the file permissions even when task commands are Off.
|
|
59
|
+
|
|
60
|
+
The task executor provides a private scratch directory through `TMPDIR`, `TMP`,
|
|
61
|
+
and `TEMP`. Only that directory is writable in addition to the selected project
|
|
62
|
+
and explicit file grants; a protected anchor prevents replacing its root with a
|
|
63
|
+
symlink. It is separate from Pi's runtime control files.
|
|
64
|
+
|
|
65
|
+
The currently admitted model-tool implementations are `read`, `write`, `edit`,
|
|
66
|
+
and `bash`. User-entered `!` and `!!` commands use the same shell policy. Other
|
|
67
|
+
model-callable tools require a verified execution adapter and are refused while
|
|
68
|
+
that actor's sandbox is enabled; enabling network alone does not admit them.
|
|
69
|
+
Subagent launch output identifies requested tools that are unavailable. Main
|
|
70
|
+
remains Off by default, so its ordinary orchestration tools remain available
|
|
71
|
+
unless the user enables Main confinement.
|
|
72
|
+
|
|
73
|
+
Pi and installed runtime extensions remain trusted code, including their
|
|
74
|
+
initialization, provider hooks, and internal `pi.exec` calls. The tool gate is
|
|
75
|
+
not a sandbox around malicious runtime extensions. Loaded runtime code,
|
|
76
|
+
configuration, and policy/control files are protected from task writes, even
|
|
77
|
+
under broader file grants. Task access to `~/.pi` does not receive a blanket
|
|
78
|
+
write allowance or lock-file exception.
|
|
79
|
+
|
|
80
|
+
Local confinement cannot govern a remote host's filesystem. Dedicated SSH,
|
|
81
|
+
MCP, scripting, background, and nested-agent tools currently lack admission
|
|
82
|
+
adapters and fail closed under an enabled actor profile. SSH through confined
|
|
83
|
+
`bash` receives local file, credential, and network restrictions; remote effects
|
|
84
|
+
remain outside the local filesystem policy.
|
|
65
85
|
|
|
66
86
|
Overriding `write` and `edit` changes nothing you can see: the parameter
|
|
67
87
|
schemas, prompt guidance, call rendering, write previews, edit diffs, result
|
|
@@ -71,7 +91,7 @@ operations underneath them are replaced.
|
|
|
71
91
|
## Commands
|
|
72
92
|
|
|
73
93
|
```text
|
|
74
|
-
/sandbox
|
|
94
|
+
/sandbox open the permission table (text status outside TUI)
|
|
75
95
|
/sandbox on enable protection for operations started from now on
|
|
76
96
|
/sandbox off turn protection off for this session (interactive confirmation)
|
|
77
97
|
/sandbox default on persist opt-in and enable it now
|
|
@@ -164,9 +184,11 @@ in your rule set but is held out in that project, with a message saying so.
|
|
|
164
184
|
## Lifecycle
|
|
165
185
|
|
|
166
186
|
The foreground sandbox is inactive by default. Session overrides do not survive
|
|
167
|
-
startup, new session, resume, fork, or reload.
|
|
168
|
-
|
|
169
|
-
|
|
187
|
+
startup, new session, resume, fork, or reload. Save as defaults writes both
|
|
188
|
+
profiles to `~/.pi/agent/extensions/pi-better-sandbox-permissions.json` (or the
|
|
189
|
+
corresponding `$PI_CODING_AGENT_DIR`). Existing activation preferences in
|
|
190
|
+
`pi-better-sandbox-preferences.json` migrate when no profile file exists.
|
|
191
|
+
`/sandbox default on|off` remains available and updates Main's saved switch.
|
|
170
192
|
|
|
171
193
|
Toggles apply to operations launched after the change. A command already running
|
|
172
194
|
keeps the policy it launched with.
|
|
@@ -198,6 +220,20 @@ that exact file, and an alias pointing at a denied file is denied too.
|
|
|
198
220
|
| Linux | Bubblewrap (`bwrap`) | install `bubblewrap` |
|
|
199
221
|
| Other | none | protected commands are blocked |
|
|
200
222
|
|
|
223
|
+
A detached subagent gets a private session/temp directory for Pi's runtime
|
|
224
|
+
state. This directory stays writable even with Outside project set to Read or
|
|
225
|
+
Off; other runs' state and the parent's launch metadata are not included.
|
|
226
|
+
Read access to system executable and library directories, device I/O, and root
|
|
227
|
+
directory metadata/listing remains available so a process can start. On macOS,
|
|
228
|
+
the allowance excludes the broad `/System/Volumes` tree.
|
|
229
|
+
|
|
230
|
+
Linux currently refuses combinations it cannot safely mount: hiding the
|
|
231
|
+
project or credential files inside a visible whole-filesystem bind, writing
|
|
232
|
+
credential stores under a read-only outside root, and a writable outside root
|
|
233
|
+
with protected paths. These launch errors preserve the selected restrictions.
|
|
234
|
+
The macOS permission combinations are covered by real-kernel tests; Linux
|
|
235
|
+
mount behavior requires a Linux runner with Bubblewrap and user namespaces.
|
|
236
|
+
|
|
201
237
|
## For other extensions
|
|
202
238
|
|
|
203
239
|
The effective policy is published as a frozen snapshot on Pi's extension event
|
package/commands.ts
CHANGED
|
@@ -27,7 +27,7 @@ import type { ForegroundSandboxController, ForegroundSandboxStatus } from "./sta
|
|
|
27
27
|
export const SANDBOX_COMMAND_NAME = "sandbox";
|
|
28
28
|
|
|
29
29
|
export const SANDBOX_COMMAND_DESCRIPTION =
|
|
30
|
-
"
|
|
30
|
+
"Configure Main and Subagents sandbox permissions, activation defaults, and protected paths";
|
|
31
31
|
|
|
32
32
|
const USAGE = [
|
|
33
33
|
"Usage:",
|
|
@@ -80,6 +80,7 @@ export type SandboxCommandDeps = {
|
|
|
80
80
|
onStateChange: (status: ForegroundSandboxStatus) => void;
|
|
81
81
|
/** Persist a default and apply it to the current session. */
|
|
82
82
|
setDefault: (enabled: boolean) => ForegroundSandboxStatus;
|
|
83
|
+
openPermissions?: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
83
84
|
};
|
|
84
85
|
|
|
85
86
|
/** Build the `/sandbox` handler. Exported so its behaviour is directly testable. */
|
|
@@ -88,6 +89,7 @@ export function createSandboxCommandHandler({
|
|
|
88
89
|
denyRules,
|
|
89
90
|
onStateChange,
|
|
90
91
|
setDefault,
|
|
92
|
+
openPermissions,
|
|
91
93
|
}: SandboxCommandDeps) {
|
|
92
94
|
return async function handleSandboxCommand(
|
|
93
95
|
args: string,
|
|
@@ -101,6 +103,10 @@ export function createSandboxCommandHandler({
|
|
|
101
103
|
const rest = trimmed.slice(verb.length).trim();
|
|
102
104
|
|
|
103
105
|
if (subcommand === "") {
|
|
106
|
+
if (openPermissions && ctx.mode === "tui") {
|
|
107
|
+
await openPermissions(ctx);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
104
110
|
ctx.ui.notify(formatSandboxReport(controller.status()), "info");
|
|
105
111
|
return;
|
|
106
112
|
}
|
package/files.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* be re-targeted between the check and the syscall: the guard and the `fs` call
|
|
17
17
|
* are the same operation.
|
|
18
18
|
*
|
|
19
|
-
* Reads
|
|
20
|
-
*
|
|
19
|
+
* Reads and mutations use the same canonical file-permission decisions as the
|
|
20
|
+
* kernel policy; protected-path write denials remain stricter than broad grants.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { constants } from "node:fs";
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type CompiledSandboxWritePolicy,
|
|
35
35
|
compileWritePolicy,
|
|
36
36
|
evaluateWriteAccess,
|
|
37
|
+
evaluateReadAccess,
|
|
37
38
|
type SandboxSeams,
|
|
38
39
|
type SandboxWritePolicy,
|
|
39
40
|
type WriteAccessDecision,
|
|
@@ -77,9 +78,9 @@ function explainDenial(
|
|
|
77
78
|
): string {
|
|
78
79
|
const attempt = kind === "directory" ? "create directory" : "write";
|
|
79
80
|
const refused = `Foreground sandbox refused to ${attempt} ${decision.path}; nothing was changed on disk.`;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
if (decision.reason === "outside-writable-root") return `${refused} Writes are confined to ${policy.writableRoot}.`;
|
|
82
|
+
if (decision.reason === "write-denied") return `${refused} ${decision.deniedBy} is a write-denied path.`;
|
|
83
|
+
return `${refused} The selected file permissions do not allow this write.`;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
/**
|
|
@@ -95,7 +96,7 @@ export type ForegroundWriteGuard = (absolutePath: string, kind?: MutationKind) =
|
|
|
95
96
|
|
|
96
97
|
/** Identity of a compiled policy, so it is recompiled on change and not per mutation. */
|
|
97
98
|
function policyKey(policy: SandboxWritePolicy): string {
|
|
98
|
-
return JSON.stringify(
|
|
99
|
+
return JSON.stringify(policy);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
/**
|
|
@@ -137,6 +138,17 @@ export function createForegroundWriteGuard(
|
|
|
137
138
|
};
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/** Enforce reads in Pi's in-process file tools using the same canonical policy. */
|
|
142
|
+
export function createForegroundReadGuard(controller: ForegroundSandboxController, seams: SandboxSeams = {}) {
|
|
143
|
+
return (absolutePath: string): string => {
|
|
144
|
+
const plan = controller.requireLaunchPlan();
|
|
145
|
+
if (!plan.confined) return absolutePath;
|
|
146
|
+
const decision = evaluateReadAccess(absolutePath, compileWritePolicy(plan.policy, seams), seams);
|
|
147
|
+
if (!decision.allowed) throw new Error(`Foreground sandbox refused to read ${decision.path}: ${decision.reason}.`);
|
|
148
|
+
return decision.path;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
/** Pi's own default local backends, which the guarded operations delegate to. */
|
|
141
153
|
const localWriteOperations: WriteOperations = {
|
|
142
154
|
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
|
@@ -189,18 +201,19 @@ export function createSandboxedWriteOperations(
|
|
|
189
201
|
*
|
|
190
202
|
* `access` is Pi's own pre-flight gate for `edit`, so guarding it refuses a
|
|
191
203
|
* denied target before the file is read or a diff is computed; `writeFile` is
|
|
192
|
-
* guarded because it is the mutation. `readFile`
|
|
193
|
-
*
|
|
204
|
+
* guarded because it is the mutation. `readFile` also checks the selected read
|
|
205
|
+
* permissions before accessing content.
|
|
194
206
|
*/
|
|
195
207
|
export function createSandboxedEditOperations(
|
|
196
208
|
controller: ForegroundSandboxController,
|
|
197
209
|
options: SandboxedEditOperationsOptions = {},
|
|
198
210
|
): EditOperations {
|
|
199
211
|
const assertWritable = createForegroundWriteGuard(controller, options);
|
|
212
|
+
const assertReadable = createForegroundReadGuard(controller, options);
|
|
200
213
|
const local = options.localOperations ?? localEditOperations;
|
|
201
214
|
|
|
202
215
|
return {
|
|
203
|
-
readFile: (absolutePath) => local.readFile(absolutePath),
|
|
216
|
+
readFile: (absolutePath) => local.readFile(assertReadable(absolutePath)),
|
|
204
217
|
async access(absolutePath) {
|
|
205
218
|
return local.access(assertWritable(absolutePath));
|
|
206
219
|
},
|
package/index.ts
CHANGED
|
@@ -5,19 +5,17 @@
|
|
|
5
5
|
* keep starting Pi with plain `pi`. While enabled, the built-in `bash` tool and
|
|
6
6
|
* user-entered `!` / `!!` commands run under macOS Seatbelt or Linux Bubblewrap
|
|
7
7
|
* with one writable root — the canonical directory Pi was launched from — and
|
|
8
|
-
* the packaged write-denied paths carved back out of it.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* in-process containment check instead. Reads and network are untouched.
|
|
8
|
+
* the packaged write-denied paths carved back out of it. Selected file,
|
|
9
|
+
* credential-file, command, and network permissions apply to protected tools.
|
|
10
|
+
* File-tool syscalls and shell commands run under the same kernel policy.
|
|
12
11
|
*
|
|
13
12
|
* This is a tool-execution sandbox. Pi's own process, `pi.exec` calls, and
|
|
14
13
|
* unrelated third-party extension code are not confined by it.
|
|
15
14
|
*/
|
|
16
15
|
|
|
16
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
17
18
|
import {
|
|
18
|
-
createBashToolDefinition,
|
|
19
|
-
createEditToolDefinition,
|
|
20
|
-
createWriteToolDefinition,
|
|
21
19
|
SettingsManager,
|
|
22
20
|
type ExtensionAPI,
|
|
23
21
|
type ExtensionContext,
|
|
@@ -34,12 +32,12 @@ import {
|
|
|
34
32
|
FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL,
|
|
35
33
|
publishForegroundSandboxPolicy,
|
|
36
34
|
} from "./events.ts";
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} from "./
|
|
41
|
-
import {
|
|
42
|
-
|
|
35
|
+
import { installTaskTools, runtimeCodeRoot } from "./shared-task-sandbox.ts";
|
|
36
|
+
import { writeSandboxDefault } from "./preferences.ts";
|
|
37
|
+
import { readPermissionSettings, writePermissionSettings } from "./permission-settings.ts";
|
|
38
|
+
import { defaultSandboxPermissions } from "./permissions.ts";
|
|
39
|
+
import { openPermissionsPage } from "./permissions-page.ts";
|
|
40
|
+
|
|
43
41
|
import { footerTone, formatFooterStatus } from "./status.ts";
|
|
44
42
|
import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
|
|
45
43
|
|
|
@@ -51,37 +49,36 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
51
49
|
// Pi's shell setting is only readable once a session directory is known, so
|
|
52
50
|
// it is resolved lazily and re-read on every session start.
|
|
53
51
|
let shellPath: string | undefined;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
pi.registerTool(createBashToolDefinition(process.cwd(), { operations }));
|
|
61
|
-
|
|
62
|
-
// The same backend for user-entered ! and !! commands.
|
|
63
|
-
pi.on("user_bash", () => ({ operations }));
|
|
64
|
-
|
|
65
|
-
// Overriding the built-in write and edit tools the same way: only their
|
|
66
|
-
// file operations change, so Pi's own definitions keep the parameter
|
|
67
|
-
// schemas, prompt guidance, call rendering, write previews, edit diffs,
|
|
68
|
-
// result details, file-mutation queue, and cancellation checks. The guarded
|
|
69
|
-
// operations run inside that queue, which is where the enforcement belongs.
|
|
70
|
-
const writeOperations = createSandboxedWriteOperations(controller);
|
|
71
|
-
const editOperations = createSandboxedEditOperations(controller);
|
|
52
|
+
const ownEntry = fileURLToPath(import.meta.url);
|
|
53
|
+
const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
|
|
54
|
+
trustedSources: [ownEntry,
|
|
55
|
+
join(dirname(ownEntry), "../../extensions/sandbox/index.ts"),
|
|
56
|
+
join(dirname(ownEntry), "../pi-better-harness/extensions/sandbox/index.ts")],
|
|
57
|
+
});
|
|
72
58
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
59
|
+
pi.on("tool_call", (event) => {
|
|
60
|
+
const status = controller.status();
|
|
61
|
+
if (status.state === "inactive" || status.state === "disabled") return;
|
|
62
|
+
const permissions = status.permissions;
|
|
63
|
+
if (!permissions) return;
|
|
64
|
+
const name = event.toolName;
|
|
65
|
+
const action = (event.input as { action?: string }).action;
|
|
66
|
+
const launch = ["bash", "powershell", "remote_bash", "subagent_spawn", "subagent_spawn_batch", "bg_task_spawn", "bg_task_watch"].includes(name) ||
|
|
67
|
+
(name === "bg_task" && (action === "spawn" || action === "watch"));
|
|
68
|
+
if (!permissions.commands && (launch || name === "grep" || name === "find")) {
|
|
69
|
+
return { block: true, reason: "Sandbox: Run commands & applications is Off. Change it in /sandbox to launch work." };
|
|
70
|
+
}
|
|
71
|
+
if (!permissions.network && (["web_search", "web_fetch", "firecrawl_scrape", "firecrawl_extract", "remote_bash", "mcp", "mcpScript"].includes(name) || name.startsWith("mcp__") ||
|
|
72
|
+
(launch && Boolean((event.input as { ssh?: unknown }).ssh)))) {
|
|
73
|
+
return { block: true, reason: "Sandbox: Network access is Off." };
|
|
74
|
+
}
|
|
75
|
+
if (name === "powershell" || name === "remote_bash") {
|
|
76
|
+
return { block: true, reason: `Sandbox: ${name} is not a confined execution surface; use bash (including ssh through bash).` };
|
|
77
|
+
}
|
|
78
|
+
if (status.readPolicy === "restricted" && ["grep", "find", "ls"].includes(name)) {
|
|
79
|
+
return { block: true, reason: "Sandbox: use the guarded read tool or a confined bash command for restricted file access." };
|
|
80
|
+
}
|
|
81
|
+
});
|
|
85
82
|
|
|
86
83
|
let paintFooter: ((status: ForegroundSandboxStatus) => void) | undefined;
|
|
87
84
|
|
|
@@ -102,7 +99,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
102
99
|
|
|
103
100
|
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
104
101
|
shellPath = resolveShellPath(ctx.cwd);
|
|
105
|
-
|
|
102
|
+
boundary.register(ctx.cwd);
|
|
106
103
|
paintFooter = (status) => {
|
|
107
104
|
ctx.ui.setStatus(
|
|
108
105
|
FOOTER_KEY,
|
|
@@ -110,19 +107,28 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
110
107
|
);
|
|
111
108
|
};
|
|
112
109
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
let defaultEnabled = false;
|
|
110
|
+
// Re-read saved profiles on each session. Malformed policy blocks protected
|
|
111
|
+
// operations rather than silently widening a restricted session.
|
|
112
|
+
let settings = defaultSandboxPermissions();
|
|
117
113
|
try {
|
|
118
|
-
|
|
114
|
+
settings = readPermissionSettings();
|
|
119
115
|
} catch (error) {
|
|
120
|
-
ctx.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
controller.beginSession(ctx.cwd, true);
|
|
117
|
+
const message = `Sandbox permissions could not be loaded: ${error instanceof Error ? error.message : String(error)}`;
|
|
118
|
+
settings.main.enabled = true;
|
|
119
|
+
settings.main.commands = false;
|
|
120
|
+
settings.subagents.commands = false;
|
|
121
|
+
controller.setPermissionSettings(settings);
|
|
122
|
+
announce(controller.block(message));
|
|
123
|
+
ctx.ui.notify(message, "error");
|
|
124
|
+
return;
|
|
124
125
|
}
|
|
125
|
-
controller.beginSession(ctx.cwd,
|
|
126
|
+
controller.beginSession(ctx.cwd, settings.main.enabled);
|
|
127
|
+
controller.protectRuntimePaths((pi.getAllTools?.() ?? [])
|
|
128
|
+
.map((tool) => tool.sourceInfo?.path).filter((path): path is string => typeof path === "string" && isAbsolute(path))
|
|
129
|
+
.map(runtimeCodeRoot));
|
|
130
|
+
controller.setPermissionSettings(settings);
|
|
131
|
+
controller.applyDefault(settings.main.enabled);
|
|
126
132
|
|
|
127
133
|
// Then the rules are re-read and re-resolved, because the same global
|
|
128
134
|
// template set means different absolute paths in a different project.
|
|
@@ -160,8 +166,18 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
160
166
|
denyRules,
|
|
161
167
|
onStateChange: announce,
|
|
162
168
|
setDefault: (enabled) => {
|
|
169
|
+
const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
|
|
170
|
+
settings.main.enabled = enabled;
|
|
171
|
+
writePermissionSettings(settings);
|
|
163
172
|
writeSandboxDefault(enabled ? "on" : "off");
|
|
164
|
-
return controller.
|
|
173
|
+
return controller.setPermissionSettings(settings);
|
|
174
|
+
},
|
|
175
|
+
openPermissions: async (ctx) => {
|
|
176
|
+
await openPermissionsPage(ctx, {
|
|
177
|
+
getConfig: () => controller.permissionSettings() ?? defaultSandboxPermissions(),
|
|
178
|
+
change: (settings) => announce(controller.setPermissionSettings(settings)),
|
|
179
|
+
save: (settings) => writePermissionSettings(settings),
|
|
180
|
+
});
|
|
165
181
|
},
|
|
166
182
|
}),
|
|
167
183
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-sandbox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.ts",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
35
|
-
"pretest": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
36
|
-
"prepack": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
34
|
+
"pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
35
|
+
"pretest": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
36
|
+
"prepack": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
38
|
"test": "node --import tsx --test test/*.test.ts",
|
|
39
39
|
"verify": "npm run typecheck && npm test"
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"LICENSE"
|
|
45
45
|
],
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": "
|
|
47
|
+
"@earendil-works/pi-coding-agent": ">=0.82.1",
|
|
48
48
|
"@earendil-works/pi-tui": "*",
|
|
49
49
|
"typebox": "*"
|
|
50
50
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Versioned storage for the permission table; does not activate enforcement. */
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { defaultSandboxPermissions, parseSandboxPermissions, type SandboxPermissionSettings } from "./permissions.ts";
|
|
7
|
+
import { readSandboxDefault, type SandboxPreferenceSeams } from "./preferences.ts";
|
|
8
|
+
|
|
9
|
+
export function permissionSettingsPath(seams: SandboxPreferenceSeams = {}): string {
|
|
10
|
+
return join((seams.agentDir ?? getAgentDir)(), "extensions", "pi-better-sandbox-permissions.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readPermissionSettings(seams: SandboxPreferenceSeams = {}): SandboxPermissionSettings {
|
|
14
|
+
let raw: string;
|
|
15
|
+
try {
|
|
16
|
+
raw = readFileSync(permissionSettingsPath(seams), "utf8");
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
19
|
+
const settings = defaultSandboxPermissions();
|
|
20
|
+
settings.main.enabled = readSandboxDefault(seams) === "on";
|
|
21
|
+
return settings;
|
|
22
|
+
}
|
|
23
|
+
const parsed: unknown = JSON.parse(raw);
|
|
24
|
+
if (!parsed || typeof parsed !== "object" || (parsed as { version?: unknown }).version !== 1) {
|
|
25
|
+
throw new Error("Unsupported sandbox permissions file version.");
|
|
26
|
+
}
|
|
27
|
+
return parseSandboxPermissions((parsed as { permissions?: unknown }).permissions);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writePermissionSettings(
|
|
31
|
+
settings: SandboxPermissionSettings,
|
|
32
|
+
seams: SandboxPreferenceSeams = {},
|
|
33
|
+
): void {
|
|
34
|
+
const permissions = parseSandboxPermissions(settings);
|
|
35
|
+
const path = permissionSettingsPath(seams);
|
|
36
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
37
|
+
const pending = `${path}.${randomUUID()}.tmp`;
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(pending, JSON.stringify({ version: 1, permissions }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
40
|
+
renameSync(pending, path);
|
|
41
|
+
} finally {
|
|
42
|
+
rmSync(pending, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|