@erichll/pi-sandbox 0.22.0 → 0.23.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/CHANGELOG.md +39 -0
- package/README.md +57 -9
- package/package.json +2 -2
- package/src/config.ts +42 -7
- package/src/index.ts +8 -3
- package/src/pi-subagents-native.ts +16 -1
- package/src/policy.ts +7 -3
- package/src/runner.ts +13 -7
- package/src/runtime.ts +79 -0
- package/src/subagent.ts +17 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.0 - 2026-10-02
|
|
4
|
+
|
|
5
|
+
- Add opt-in `network.strictAllowlist` (default `false`) to deny unmatched
|
|
6
|
+
destinations without model or human approval. Forward the setting to Sandbox
|
|
7
|
+
Runtime for Bash and built-in workers, including persistent follow-up and
|
|
8
|
+
nested handoff.
|
|
9
|
+
- Merge global/project strict mode with logical OR while retaining domain-list
|
|
10
|
+
unions. Both files remain trusted policy sources: projects can add endpoints
|
|
11
|
+
but cannot disable globally required strict enforcement.
|
|
12
|
+
- Reject strict mode combined with Host-IPC `ask` after configuration merging
|
|
13
|
+
and embedding overrides. Keep non-strict approval and host forwarding behavior.
|
|
14
|
+
- Require `@erichll/pi-auto-review` `^0.23.0` for current-session review models
|
|
15
|
+
and full-command execution-context validation.
|
|
16
|
+
- Cover real Linux broker network enforcement, persistent worker lifecycle and
|
|
17
|
+
startup failures with synthetic endpoints; isolate configuration tests from
|
|
18
|
+
the developer's working directory.
|
|
19
|
+
|
|
20
|
+
## 0.22.1 - 2026-10-01
|
|
21
|
+
|
|
22
|
+
- Fix sandboxed Bash on Bun-compiled Pi: launching the Sandbox Runtime broker
|
|
23
|
+
could instead start another unsandboxed Pi session with the broker path as
|
|
24
|
+
its prompt, leaving the requested command unexecuted
|
|
25
|
+
([#7](https://github.com/erichll/pi-packages/issues/7)). The broker now runs
|
|
26
|
+
through a separate Node executable and communicates over JSON IPC.
|
|
27
|
+
- **Bun-compiled Pi requires Node.js >=22.19.0 on PATH**, with its directory
|
|
28
|
+
listed as an absolute path. If Node cannot be found, commands fail explicitly
|
|
29
|
+
before execution. Aliases back to the Pi executable are rejected.
|
|
30
|
+
- Fix built-in subagent startup for standalone Pi and resolve the host CLI
|
|
31
|
+
symlink for Node-based Pi. Derive filesystem permissions from the actual
|
|
32
|
+
runtime so a binary such as `/tmp/pi-bun` does not grant root reads or deny
|
|
33
|
+
writes to the entire host temp directory.
|
|
34
|
+
- Fix protected `pi-subagents` loading on Bun when `pi-tui` and `typebox` are
|
|
35
|
+
provided by Pi. Reuse the host modules in the internal loader so compiled
|
|
36
|
+
Pi does not need separate copies beside the extension.
|
|
37
|
+
- Clean up command temp directories when broker startup fails. Add isolated
|
|
38
|
+
Node, plain Bun, and compiled Bun regression tests for broker startup,
|
|
39
|
+
capability-ceiling registration, and rejection of enabled schedules.
|
|
40
|
+
- Require `@erichll/pi-auto-review` `^0.22.1` to include the Bun SQLite audit fix.
|
|
41
|
+
|
|
3
42
|
## 0.22.0 - 2026-09-30
|
|
4
43
|
|
|
5
44
|
- Raise the `@earendil-works/pi-coding-agent` peer floor to `^0.99.1` and the
|
package/README.md
CHANGED
|
@@ -69,6 +69,12 @@ configure an AppArmor profile for bubblewrap or disable
|
|
|
69
69
|
|
|
70
70
|
macOS uses built-in Seatbelt support but still requires `ripgrep`.
|
|
71
71
|
|
|
72
|
+
For Bun-compiled standalone Pi, install **Node.js >=22.19.0** and include its
|
|
73
|
+
`bin` directory as an absolute entry on `PATH`. The sandbox broker runs in that
|
|
74
|
+
separate Node process; built-in subagents launch the Pi executable directly.
|
|
75
|
+
If no usable `node` is found, the command fails before execution. Node-based Pi
|
|
76
|
+
and ordinary `bun cli.js` launches use their existing JavaScript runtime.
|
|
77
|
+
|
|
72
78
|
## Subagent provider
|
|
73
79
|
|
|
74
80
|
The provider is selected from the configuration (`~/.pi/agent/extensions/pi-sandbox/config.json` globally or `.pi/extensions/pi-sandbox/config.json` project-locally):
|
|
@@ -94,10 +100,11 @@ Supported modes:
|
|
|
94
100
|
This provider supports the `pi-subagents >=0.66.0` line (validated through
|
|
95
101
|
0.72.1) and fails closed if its public ceiling API, module layout, or internal
|
|
96
102
|
discovery layout drifts. The peer dependency is a floor with no upper pin; the
|
|
97
|
-
loader's explicit export and layout checks are the real gate.
|
|
98
|
-
aliases `@earendil-works/pi-tui
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
loader's explicit export and layout checks are the real gate. On Node, the
|
|
104
|
+
loader aliases the `@earendil-works/pi-tui` host peer to the running Pi package.
|
|
105
|
+
On Bun, including compiled Pi, it passes Pi's `pi-tui` and `typebox` module
|
|
106
|
+
objects into its internal loader, so those peers can be embedded in Pi instead
|
|
107
|
+
of installed beside `pi-subagents`.
|
|
101
108
|
Configure both the protection mode and a non-empty canonical whitelist:
|
|
102
109
|
|
|
103
110
|
```json
|
|
@@ -150,6 +157,7 @@ Domain authorization can be configured globally at
|
|
|
150
157
|
```json
|
|
151
158
|
{
|
|
152
159
|
"network": {
|
|
160
|
+
"strictAllowlist": false,
|
|
153
161
|
"allowedDomains": [
|
|
154
162
|
"github.com",
|
|
155
163
|
"*.github.com",
|
|
@@ -172,14 +180,50 @@ The precedence is deterministic:
|
|
|
172
180
|
|
|
173
181
|
1. A matching `deniedDomains` entry rejects the connection.
|
|
174
182
|
2. Otherwise, a matching `allowedDomains` entry permits it without review.
|
|
175
|
-
3. Otherwise,
|
|
183
|
+
3. Otherwise, if `strictAllowlist` is `true`, the connection is denied without
|
|
184
|
+
invoking the Runtime ask callback, model review, or human approval.
|
|
185
|
+
4. Otherwise, the canonical public hostname and port enter the existing
|
|
176
186
|
one-shot auto-review or human approval flow.
|
|
177
187
|
|
|
178
|
-
Both arrays default to empty
|
|
179
|
-
|
|
180
|
-
|
|
188
|
+
Both arrays default to empty and `strictAllowlist` defaults to `false`, preserving
|
|
189
|
+
the existing per-connection approval behavior. The setting accepts only a boolean.
|
|
190
|
+
With strict mode and an empty allowlist, all proxied destinations are denied.
|
|
191
|
+
`deniedDomains: ["*"]` also blocks allowed entries because deny rules take priority.
|
|
192
|
+
The same network policy governs main-agent Bash, user Bash, built-in subagents, and the
|
|
181
193
|
sandboxed Bash tool loaded by protected native `pi-subagents` children.
|
|
182
194
|
|
|
195
|
+
For a worker restricted to explicitly approved inference endpoints:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"subagents": { "provider": "builtin" },
|
|
200
|
+
"network": {
|
|
201
|
+
"strictAllowlist": true,
|
|
202
|
+
"allowedDomains": ["inference.example.com:443"],
|
|
203
|
+
"deniedDomains": []
|
|
204
|
+
},
|
|
205
|
+
"hostIPC": { "mode": "off" }
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Replace the example hostname with your approved endpoint. Global and project
|
|
210
|
+
`strictAllowlist` values merge with logical OR: a project cannot disable strict
|
|
211
|
+
mode required globally. Domain lists still form unions, so a project **can add
|
|
212
|
+
allowed destinations**. Both files are trusted policy inputs; the global allowlist
|
|
213
|
+
is not an administrative upper bound on project permissions.
|
|
214
|
+
|
|
215
|
+
Strict mode cannot be combined with `hostIPC.mode: "ask"`, since host execution
|
|
216
|
+
would escape the network sandbox. The effective configuration is rejected before
|
|
217
|
+
tools or workers are created; set Host-IPC mode to `off`. This is checked after
|
|
218
|
+
global/project merging, and again after an embedding's Host-IPC override.
|
|
219
|
+
|
|
220
|
+
Configuration is loaded when the extension is registered. Existing persistent
|
|
221
|
+
workers keep their policy through follow-up, and handoff workers receive the same
|
|
222
|
+
configured policy. Reload the extension and create new workers to apply file
|
|
223
|
+
changes; there is no configuration hot reload. Broker startup failure never retries
|
|
224
|
+
the worker outside the sandbox, and unavailable approval services cannot relax a
|
|
225
|
+
strict network denial.
|
|
226
|
+
|
|
183
227
|
Domain allowlists are not a complete data-loss-prevention boundary. A
|
|
184
228
|
multi-tenant or user-uploadable destination such as `github.com` can itself be
|
|
185
229
|
an exfiltration channel; grant only the narrow domains and ports you intend.
|
|
@@ -222,7 +266,7 @@ approval warns that the first attempt may already have had partial side
|
|
|
222
266
|
effects. Successful, timed-out, or aborted commands are never retried.
|
|
223
267
|
|
|
224
268
|
Host forwarding is intentionally unavailable inside built-in subagents in
|
|
225
|
-
this version.
|
|
269
|
+
this version. It is also incompatible with `network.strictAllowlist: true`.
|
|
226
270
|
|
|
227
271
|
## Additional trusted read paths
|
|
228
272
|
|
|
@@ -262,8 +306,12 @@ In addition to user-level configuration at `~/.pi/agent/extensions/pi-sandbox/co
|
|
|
262
306
|
```
|
|
263
307
|
|
|
264
308
|
When present, project configuration is merged with global configuration:
|
|
309
|
+
|
|
265
310
|
- Array policies (`additionalAllowRead`, `allowedDomains`, `deniedDomains`, `preflightCommandPrefixes`) form a deduplicated union.
|
|
266
311
|
- Scalar settings (`provider`, `mode`, `retryOnUnixSocketError`) allow project-level overrides.
|
|
312
|
+
- `network.strictAllowlist` uses logical OR: `true` in either file enables it.
|
|
313
|
+
Both configuration files are trusted; project allowlist additions remain effective.
|
|
314
|
+
- The merged configuration must not enable both strict allowlist and Host-IPC `ask`.
|
|
267
315
|
|
|
268
316
|
**Read-only Security Guarantee**:
|
|
269
317
|
Project-level configuration is explicitly protected in the sandbox policy (`denyWrite`),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Linux (bubblewrap) and macOS (Seatbelt) process sandbox for Pi backed by Anthropic's @anthropic-ai/sandbox-runtime. Fail-closed static filesystem policy plus per-connection network approval from pi-auto-review; sandboxes main-agent Bash and built-in subagent process trees.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@anthropic-ai/sandbox-runtime": "^0.0.77",
|
|
54
|
-
"@erichll/pi-auto-review": "^0.
|
|
54
|
+
"@erichll/pi-auto-review": "^0.23.0",
|
|
55
55
|
"jiti": "2.7.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
package/src/config.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type HostIPCConfig = {
|
|
|
23
23
|
export type NetworkConfig = {
|
|
24
24
|
readonly allowedDomains: readonly string[];
|
|
25
25
|
readonly deniedDomains: readonly string[];
|
|
26
|
+
/** Deny unmatched destinations without review. Defaults to false. */
|
|
27
|
+
readonly strictAllowlist?: boolean;
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
export type PiSandboxConfig = {
|
|
@@ -34,7 +36,7 @@ export type PiSandboxConfig = {
|
|
|
34
36
|
filesystem: {
|
|
35
37
|
additionalAllowRead: readonly string[];
|
|
36
38
|
};
|
|
37
|
-
network: NetworkConfig;
|
|
39
|
+
network: NetworkConfig & { readonly strictAllowlist: boolean };
|
|
38
40
|
hostIPC: HostIPCConfig;
|
|
39
41
|
};
|
|
40
42
|
|
|
@@ -57,6 +59,7 @@ export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freez
|
|
|
57
59
|
network: Object.freeze({
|
|
58
60
|
allowedDomains: Object.freeze([]),
|
|
59
61
|
deniedDomains: Object.freeze([]),
|
|
62
|
+
strictAllowlist: false,
|
|
60
63
|
}),
|
|
61
64
|
hostIPC: Object.freeze({
|
|
62
65
|
mode: "off",
|
|
@@ -263,9 +266,15 @@ export function parsePiSandboxConfigRaw(value: unknown): PartialPiSandboxConfig
|
|
|
263
266
|
}
|
|
264
267
|
rejectUnknownKeys(
|
|
265
268
|
value.network,
|
|
266
|
-
["allowedDomains", "deniedDomains"],
|
|
269
|
+
["allowedDomains", "deniedDomains", "strictAllowlist"],
|
|
267
270
|
"network",
|
|
268
271
|
);
|
|
272
|
+
const strictAllowlist = value.network.strictAllowlist;
|
|
273
|
+
if (strictAllowlist !== undefined && typeof strictAllowlist !== "boolean") {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"invalid pi-sandbox configuration: network.strictAllowlist must be a boolean",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
269
278
|
const normalizeDomainList = (
|
|
270
279
|
key: "allowedDomains" | "deniedDomains",
|
|
271
280
|
): string[] | undefined => {
|
|
@@ -310,6 +319,7 @@ export function parsePiSandboxConfigRaw(value: unknown): PartialPiSandboxConfig
|
|
|
310
319
|
parsedNetwork = {
|
|
311
320
|
...(allowed !== undefined ? { allowedDomains: allowed } : {}),
|
|
312
321
|
...(denied !== undefined ? { deniedDomains: denied } : {}),
|
|
322
|
+
...(strictAllowlist !== undefined ? { strictAllowlist } : {}),
|
|
313
323
|
};
|
|
314
324
|
}
|
|
315
325
|
|
|
@@ -376,7 +386,24 @@ export function parsePiSandboxConfigRaw(value: unknown): PartialPiSandboxConfig
|
|
|
376
386
|
}
|
|
377
387
|
|
|
378
388
|
export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
|
|
379
|
-
const
|
|
389
|
+
const config = resolvePiSandboxConfig(parsePiSandboxConfigRaw(value));
|
|
390
|
+
validatePiSandboxConfig(config);
|
|
391
|
+
return config;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Validate the effective policy, after merging configuration layers. */
|
|
395
|
+
export function validatePiSandboxConfig(
|
|
396
|
+
config: { network: NetworkConfig; hostIPC: HostIPCConfig },
|
|
397
|
+
): void {
|
|
398
|
+
if (config.network.strictAllowlist && config.hostIPC.mode === "ask") {
|
|
399
|
+
throw new Error(
|
|
400
|
+
'invalid pi-sandbox configuration: network.strictAllowlist=true cannot be combined with hostIPC.mode="ask"; set hostIPC.mode="off" to keep execution sandboxed',
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Apply defaults without validating conflicts in an intermediate layer. */
|
|
406
|
+
function resolvePiSandboxConfig(raw: PartialPiSandboxConfig): PiSandboxConfig {
|
|
380
407
|
const provider =
|
|
381
408
|
raw.subagents?.provider ?? DEFAULT_PI_SANDBOX_CONFIG.subagents.provider;
|
|
382
409
|
const isPiSubagents = provider === "pi-subagents";
|
|
@@ -400,6 +427,7 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
|
|
|
400
427
|
],
|
|
401
428
|
},
|
|
402
429
|
network: {
|
|
430
|
+
strictAllowlist: raw.network?.strictAllowlist ?? false,
|
|
403
431
|
allowedDomains: [
|
|
404
432
|
...(raw.network?.allowedDomains ??
|
|
405
433
|
DEFAULT_PI_SANDBOX_CONFIG.network.allowedDomains),
|
|
@@ -435,6 +463,7 @@ function defaultPiSandboxConfig(): PiSandboxConfig {
|
|
|
435
463
|
network: {
|
|
436
464
|
allowedDomains: [...DEFAULT_PI_SANDBOX_CONFIG.network.allowedDomains],
|
|
437
465
|
deniedDomains: [...DEFAULT_PI_SANDBOX_CONFIG.network.deniedDomains],
|
|
466
|
+
strictAllowlist: DEFAULT_PI_SANDBOX_CONFIG.network.strictAllowlist,
|
|
438
467
|
},
|
|
439
468
|
hostIPC: {
|
|
440
469
|
mode: DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode,
|
|
@@ -507,7 +536,7 @@ export function mergePiSandboxConfigs(
|
|
|
507
536
|
allowedNativeAgents = [...new Set([...trustedAgents, ...projectAgents])];
|
|
508
537
|
}
|
|
509
538
|
|
|
510
|
-
|
|
539
|
+
const merged: PiSandboxConfig = {
|
|
511
540
|
subagents: {
|
|
512
541
|
provider,
|
|
513
542
|
...(isPiSubagents
|
|
@@ -526,6 +555,10 @@ export function mergePiSandboxConfigs(
|
|
|
526
555
|
],
|
|
527
556
|
},
|
|
528
557
|
network: {
|
|
558
|
+
// Both configuration files are trusted policy inputs. Projects may add
|
|
559
|
+
// destinations, but cannot turn off strict enforcement required globally.
|
|
560
|
+
strictAllowlist:
|
|
561
|
+
trusted.network.strictAllowlist || (project.network?.strictAllowlist ?? false),
|
|
529
562
|
allowedDomains: [
|
|
530
563
|
...new Set([
|
|
531
564
|
...trusted.network.allowedDomains,
|
|
@@ -552,6 +585,8 @@ export function mergePiSandboxConfigs(
|
|
|
552
585
|
trusted.hostIPC.retryOnUnixSocketError,
|
|
553
586
|
},
|
|
554
587
|
};
|
|
588
|
+
validatePiSandboxConfig(merged);
|
|
589
|
+
return merged;
|
|
555
590
|
}
|
|
556
591
|
|
|
557
592
|
export function loadPiSandboxConfig(
|
|
@@ -572,9 +607,8 @@ export function loadPiSandboxConfig(
|
|
|
572
607
|
const globalPath = getPiSandboxConfigPath(options.home);
|
|
573
608
|
let globalConfig: PiSandboxConfig | undefined;
|
|
574
609
|
try {
|
|
575
|
-
globalConfig =
|
|
576
|
-
globalPath,
|
|
577
|
-
readPiSandboxConfigFile(globalPath),
|
|
610
|
+
globalConfig = resolvePiSandboxConfig(
|
|
611
|
+
parsePiSandboxConfigFileRaw(globalPath, readPiSandboxConfigFile(globalPath)),
|
|
578
612
|
);
|
|
579
613
|
} catch (error) {
|
|
580
614
|
if (!isNotFoundError(error)) {
|
|
@@ -599,5 +633,6 @@ export function loadPiSandboxConfig(
|
|
|
599
633
|
if (projectRaw) {
|
|
600
634
|
return mergePiSandboxConfigs(base, projectRaw);
|
|
601
635
|
}
|
|
636
|
+
validatePiSandboxConfig(base);
|
|
602
637
|
return base;
|
|
603
638
|
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "./approval.ts";
|
|
18
18
|
import {
|
|
19
19
|
loadPiSandboxConfig,
|
|
20
|
+
validatePiSandboxConfig,
|
|
20
21
|
type HostIPCConfig,
|
|
21
22
|
type NetworkConfig,
|
|
22
23
|
type SubagentProvider,
|
|
@@ -220,6 +221,8 @@ async function performRegistration(
|
|
|
220
221
|
let currentTurn = 0;
|
|
221
222
|
const cwd = process.cwd();
|
|
222
223
|
const config = loadPiSandboxConfig({ cwd });
|
|
224
|
+
const hostIPC = options.hostIPC ?? config.hostIPC;
|
|
225
|
+
validatePiSandboxConfig({ network: config.network, hostIPC });
|
|
223
226
|
const subagentProvider =
|
|
224
227
|
options.subagentProvider ?? config.subagents.provider;
|
|
225
228
|
const isNativeSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
@@ -237,7 +240,6 @@ async function performRegistration(
|
|
|
237
240
|
: undefined;
|
|
238
241
|
const additionalAllowRead = config.filesystem.additionalAllowRead;
|
|
239
242
|
const network = config.network;
|
|
240
|
-
const hostIPC = options.hostIPC ?? config.hostIPC;
|
|
241
243
|
const subagents =
|
|
242
244
|
subagentProvider === "builtin"
|
|
243
245
|
? (options.subagentManager ??
|
|
@@ -625,10 +627,13 @@ async function performRegistration(
|
|
|
625
627
|
return;
|
|
626
628
|
}
|
|
627
629
|
if (ctx.hasUI) {
|
|
630
|
+
const networkMode = network.strictAllowlist
|
|
631
|
+
? "strict network allowlist (unmatched destinations denied without review)"
|
|
632
|
+
: "per-connection network review";
|
|
628
633
|
ctx.ui.notify(
|
|
629
634
|
process.platform === "darwin"
|
|
630
|
-
? `${EXTENSION_NAME} enabled: macOS Sandbox Runtime with static filesystem/domain policy and
|
|
631
|
-
: `${EXTENSION_NAME} enabled: Linux Sandbox Runtime with static filesystem/domain policy and
|
|
635
|
+
? `${EXTENSION_NAME} enabled: macOS Sandbox Runtime with static filesystem/domain policy and ${networkMode}`
|
|
636
|
+
: `${EXTENSION_NAME} enabled: Linux Sandbox Runtime with static filesystem/domain policy and ${networkMode}`,
|
|
632
637
|
"info",
|
|
633
638
|
);
|
|
634
639
|
}
|
|
@@ -3,6 +3,8 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import { createJiti } from "jiti";
|
|
6
|
+
import * as hostPiTui from "@earendil-works/pi-tui";
|
|
7
|
+
import * as hostTypebox from "typebox";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Compatible pi-subagents line: 0.66.0 and above (no upper pin). Any new minor
|
|
@@ -254,10 +256,23 @@ export async function loadPiSubagentsNativeRuntime(
|
|
|
254
256
|
}
|
|
255
257
|
const extension = resolvePiSubagentsModuleExtension(packageJson.exports);
|
|
256
258
|
|
|
259
|
+
// Pi's outer extension loader supplies these host modules, including embedded
|
|
260
|
+
// copies in compiled Bun builds. Static imports above keep host resolution in
|
|
261
|
+
// that outer loader even under plain `bun cli.js`. Filesystem aliases cannot
|
|
262
|
+
// locate modules that only exist inside Pi.
|
|
263
|
+
const resolutionOptions = process.versions.bun
|
|
264
|
+
? {
|
|
265
|
+
tryNative: false,
|
|
266
|
+
virtualModules: {
|
|
267
|
+
[HOST_PI_TUI_PACKAGE]: hostPiTui,
|
|
268
|
+
typebox: hostTypebox,
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
: { alias: await resolvePiSubagentsHostAliases() };
|
|
257
272
|
const jiti = createJiti(import.meta.url, {
|
|
258
273
|
interopDefault: false,
|
|
259
274
|
fsCache: false,
|
|
260
|
-
|
|
275
|
+
...resolutionOptions,
|
|
261
276
|
});
|
|
262
277
|
const internal = (relative: string): string =>
|
|
263
278
|
pathToFileURL(join(root, piSubagentsInternalModulePath(relative, extension))).href;
|
package/src/policy.ts
CHANGED
|
@@ -10,13 +10,13 @@ import {
|
|
|
10
10
|
} from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
13
|
+
import { runtimeFilesystemPaths } from "./runtime.ts";
|
|
13
14
|
import {
|
|
14
15
|
PROJECT_PI_SANDBOX_CONFIG_PATH,
|
|
15
16
|
type NetworkConfig,
|
|
16
17
|
} from "./config.ts";
|
|
17
18
|
|
|
18
19
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
19
|
-
const NODE_INSTALL_ROOT = dirname(dirname(process.execPath));
|
|
20
20
|
const SANDBOX_RUNTIME_ROOT = dirname(
|
|
21
21
|
dirname(
|
|
22
22
|
fileURLToPath(import.meta.resolve("@anthropic-ai/sandbox-runtime")),
|
|
@@ -115,6 +115,7 @@ export type SandboxPolicy = {
|
|
|
115
115
|
network: {
|
|
116
116
|
allowedDomains: string[];
|
|
117
117
|
deniedDomains: string[];
|
|
118
|
+
strictAllowlist?: boolean;
|
|
118
119
|
allowLocalBinding: boolean;
|
|
119
120
|
allowAllUnixSockets: boolean;
|
|
120
121
|
allowUnixSockets: string[];
|
|
@@ -231,6 +232,7 @@ export function createDefaultPolicy(
|
|
|
231
232
|
): SandboxPolicy {
|
|
232
233
|
const workspace = resolve(cwd);
|
|
233
234
|
const home = resolve(homedir());
|
|
235
|
+
const runtimePaths = runtimeFilesystemPaths();
|
|
234
236
|
const denyRead = home === parse(home).root ? [] : [home];
|
|
235
237
|
const packageRelative = relative(workspace, PACKAGE_ROOT);
|
|
236
238
|
const packageIsInWorkspace =
|
|
@@ -241,7 +243,7 @@ export function createDefaultPolicy(
|
|
|
241
243
|
denyRead,
|
|
242
244
|
allowRead: [
|
|
243
245
|
workspace,
|
|
244
|
-
|
|
246
|
+
...runtimePaths.allowRead,
|
|
245
247
|
SANDBOX_RUNTIME_ROOT,
|
|
246
248
|
join(home, ".gitconfig"),
|
|
247
249
|
join(home, ".config", "git", "config"),
|
|
@@ -265,13 +267,14 @@ export function createDefaultPolicy(
|
|
|
265
267
|
"config.json",
|
|
266
268
|
),
|
|
267
269
|
...(packageIsInWorkspace ? [] : [PACKAGE_ROOT]),
|
|
268
|
-
|
|
270
|
+
...runtimePaths.denyWrite,
|
|
269
271
|
...createWorkspaceSecretDenyWritePaths(workspace),
|
|
270
272
|
],
|
|
271
273
|
},
|
|
272
274
|
network: {
|
|
273
275
|
allowedDomains: [...(options.network?.allowedDomains ?? [])],
|
|
274
276
|
deniedDomains: [...(options.network?.deniedDomains ?? [])],
|
|
277
|
+
strictAllowlist: options.network?.strictAllowlist ?? false,
|
|
275
278
|
allowLocalBinding: false,
|
|
276
279
|
allowAllUnixSockets: false,
|
|
277
280
|
allowUnixSockets: [],
|
|
@@ -295,6 +298,7 @@ export function toSandboxRuntimeConfig(
|
|
|
295
298
|
network: {
|
|
296
299
|
allowedDomains: [...policy.network.allowedDomains],
|
|
297
300
|
deniedDomains: [...policy.network.deniedDomains],
|
|
301
|
+
strictAllowlist: policy.network.strictAllowlist ?? false,
|
|
298
302
|
allowLocalBinding: policy.network.allowLocalBinding,
|
|
299
303
|
allowAllUnixSockets: policy.network.allowAllUnixSockets,
|
|
300
304
|
allowUnixSockets: [...policy.network.allowUnixSockets],
|
package/src/runner.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Writable } from "node:stream";
|
|
|
11
11
|
import { getShellConfig } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
13
13
|
import type { NetworkEndpoint } from "./approval.ts";
|
|
14
|
+
import { resolveBrokerExecPath } from "./runtime.ts";
|
|
14
15
|
import {
|
|
15
16
|
createDefaultPolicy,
|
|
16
17
|
toSandboxRuntimeConfig,
|
|
@@ -173,7 +174,10 @@ function spawnBroker(
|
|
|
173
174
|
cwd: options.cwd,
|
|
174
175
|
detached: true,
|
|
175
176
|
env,
|
|
177
|
+
execPath: resolveBrokerExecPath(env),
|
|
176
178
|
execArgv: broker.execArgv ?? [],
|
|
179
|
+
// Bun's default IPC serialization is incompatible with a Node child.
|
|
180
|
+
serialization: "json",
|
|
177
181
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
178
182
|
};
|
|
179
183
|
return fork(broker.modulePath, [], forkOptions);
|
|
@@ -206,12 +210,14 @@ export async function runSandboxedCommand(
|
|
|
206
210
|
[SANDBOX_RUNTIME_TMPDIR_ENV]: tempDir,
|
|
207
211
|
};
|
|
208
212
|
}
|
|
209
|
-
|
|
213
|
+
let broker: ChildProcess | undefined;
|
|
210
214
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
211
215
|
let timedOut = false;
|
|
212
216
|
let onAbort: (() => void) | undefined;
|
|
213
217
|
|
|
214
218
|
try {
|
|
219
|
+
broker = spawnBroker(options, brokerEnv);
|
|
220
|
+
const child = broker;
|
|
215
221
|
broker.stdout?.on("data", (data: Buffer) => {
|
|
216
222
|
options.onData(data);
|
|
217
223
|
options.onStdout?.(data);
|
|
@@ -222,7 +228,7 @@ export async function runSandboxedCommand(
|
|
|
222
228
|
});
|
|
223
229
|
broker.on("message", (message: unknown) => {
|
|
224
230
|
if (!isNetworkRequest(message)) return;
|
|
225
|
-
void answerNetworkRequest(
|
|
231
|
+
void answerNetworkRequest(child, message, options);
|
|
226
232
|
});
|
|
227
233
|
|
|
228
234
|
const init: BrokerInitMessage = {
|
|
@@ -242,18 +248,18 @@ export async function runSandboxedCommand(
|
|
|
242
248
|
broker.stdin?.end();
|
|
243
249
|
}
|
|
244
250
|
|
|
245
|
-
onAbort = () => killProcessTree(
|
|
251
|
+
onAbort = () => killProcessTree(child);
|
|
246
252
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
247
253
|
if (options.timeout !== undefined && options.timeout > 0) {
|
|
248
254
|
timeoutHandle = setTimeout(() => {
|
|
249
255
|
timedOut = true;
|
|
250
|
-
killProcessTree(
|
|
256
|
+
killProcessTree(child);
|
|
251
257
|
}, options.timeout * 1_000);
|
|
252
258
|
}
|
|
253
259
|
|
|
254
260
|
const exitCode = await new Promise<number | null>((resolve, reject) => {
|
|
255
|
-
|
|
256
|
-
|
|
261
|
+
child.once("error", reject);
|
|
262
|
+
child.once("exit", resolve);
|
|
257
263
|
});
|
|
258
264
|
if (options.signal?.aborted) throw new Error("aborted");
|
|
259
265
|
if (timedOut) throw new Error(`timeout:${options.timeout}`);
|
|
@@ -261,7 +267,7 @@ export async function runSandboxedCommand(
|
|
|
261
267
|
} finally {
|
|
262
268
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
263
269
|
if (onAbort) options.signal?.removeEventListener("abort", onAbort);
|
|
264
|
-
if (broker.exitCode === null && broker.signalCode === null) {
|
|
270
|
+
if (broker && broker.exitCode === null && broker.signalCode === null) {
|
|
265
271
|
killProcessTree(broker);
|
|
266
272
|
}
|
|
267
273
|
if (tempDir) {
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, delimiter, dirname, isAbsolute, join, parse } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type RuntimeProcess = {
|
|
6
|
+
execPath: string;
|
|
7
|
+
argv: readonly string[];
|
|
8
|
+
versions: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
features?: { sea?: boolean; inspector?: boolean };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** The extension's own import.meta.url is on disk even inside compiled Pi. */
|
|
13
|
+
export function isStandaloneExecutable(host: RuntimeProcess = process): boolean {
|
|
14
|
+
return host.features?.sea === true || (
|
|
15
|
+
!!host.versions.bun &&
|
|
16
|
+
/(?:\$bunfs|~BUN|%7EBUN)/i.test(host.argv[1] ?? "")
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** fork() must execute a JS runtime, never a compiled Pi application image. */
|
|
21
|
+
export function resolveBrokerExecPath(
|
|
22
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
23
|
+
host: RuntimeProcess = process,
|
|
24
|
+
): string {
|
|
25
|
+
if (!isStandaloneExecutable(host)) return host.execPath;
|
|
26
|
+
|
|
27
|
+
for (const directory of (env.PATH ?? "").split(delimiter)) {
|
|
28
|
+
// Do not discover executable code in the command's working directory.
|
|
29
|
+
if (!isAbsolute(directory)) continue;
|
|
30
|
+
try {
|
|
31
|
+
const candidate = realpathSync(join(directory, "node"));
|
|
32
|
+
accessSync(candidate, constants.X_OK);
|
|
33
|
+
const candidateStat = statSync(candidate);
|
|
34
|
+
const hostStat = statSync(host.execPath);
|
|
35
|
+
if (!candidateStat.isFile()) continue;
|
|
36
|
+
// Also reject symlinks/hard links from `node` back to the Pi binary.
|
|
37
|
+
if (candidateStat.dev === hostStat.dev && candidateStat.ino === hostStat.ino) continue;
|
|
38
|
+
return candidate;
|
|
39
|
+
} catch {
|
|
40
|
+
// Try the next absolute PATH entry.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(
|
|
44
|
+
"pi-sandbox: standalone Pi requires Node.js >=22.19.0 on PATH to run the Sandbox Runtime broker; no usable node executable was found",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runtimeReadPath(execPath: string): string {
|
|
49
|
+
const bin = dirname(execPath);
|
|
50
|
+
const root = dirname(bin);
|
|
51
|
+
// Node installations under nvm/Nix need their libraries as well as bin/node.
|
|
52
|
+
// Arbitrarily placed executables must not grant a whole home directory or /.
|
|
53
|
+
return basename(bin) === "bin" && root !== parse(root).root && root !== homedir()
|
|
54
|
+
? root
|
|
55
|
+
: execPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function runtimeWritePath(execPath: string): string {
|
|
59
|
+
const directory = dirname(execPath);
|
|
60
|
+
return basename(directory) === "bin" ? directory : execPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function runtimeFilesystemPaths(
|
|
64
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
65
|
+
host: RuntimeProcess = process,
|
|
66
|
+
): { allowRead: string[]; denyWrite: string[] } {
|
|
67
|
+
const brokerExecPath = resolveBrokerExecPath(env, host);
|
|
68
|
+
const standalone = isStandaloneExecutable(host);
|
|
69
|
+
return {
|
|
70
|
+
allowRead: [...new Set([
|
|
71
|
+
standalone ? host.execPath : runtimeReadPath(host.execPath),
|
|
72
|
+
runtimeReadPath(brokerExecPath),
|
|
73
|
+
])],
|
|
74
|
+
denyWrite: [...new Set([
|
|
75
|
+
standalone ? host.execPath : runtimeWritePath(host.execPath),
|
|
76
|
+
runtimeWritePath(brokerExecPath),
|
|
77
|
+
])],
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/subagent.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import type { Writable } from "node:stream";
|
|
5
6
|
import type { NetworkEndpoint } from "./approval.ts";
|
|
7
|
+
import { isStandaloneExecutable, type RuntimeProcess } from "./runtime.ts";
|
|
6
8
|
import {
|
|
7
9
|
runSandboxedCommand,
|
|
8
10
|
type SandboxCommandOptions,
|
|
@@ -201,19 +203,30 @@ export function validateSubagentModel(
|
|
|
201
203
|
};
|
|
202
204
|
}
|
|
203
205
|
|
|
204
|
-
export function resolvePiInvocation(): ProcessInvocation {
|
|
205
|
-
|
|
206
|
+
export function resolvePiInvocation(host: RuntimeProcess = process): ProcessInvocation {
|
|
207
|
+
if (isStandaloneExecutable(host)) {
|
|
208
|
+
return { command: host.execPath, args: [] };
|
|
209
|
+
}
|
|
210
|
+
let currentEntry = host.argv[1];
|
|
211
|
+
if (currentEntry) {
|
|
212
|
+
try {
|
|
213
|
+
// npm's `pi` executable is usually a symlink to the host's cli.js.
|
|
214
|
+
currentEntry = realpathSync(currentEntry);
|
|
215
|
+
} catch {
|
|
216
|
+
// Preserve source/virtual entries that cannot be canonicalized.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
206
219
|
if (
|
|
207
220
|
currentEntry &&
|
|
208
221
|
/(?:^|[/\\])cli\.(?:js|mjs|cjs|ts)$/.test(currentEntry)
|
|
209
222
|
) {
|
|
210
|
-
return { command:
|
|
223
|
+
return { command: host.execPath, args: [currentEntry] };
|
|
211
224
|
}
|
|
212
225
|
const packageEntry = fileURLToPath(
|
|
213
226
|
import.meta.resolve("@earendil-works/pi-coding-agent"),
|
|
214
227
|
);
|
|
215
228
|
return {
|
|
216
|
-
command:
|
|
229
|
+
command: host.execPath,
|
|
217
230
|
args: [join(dirname(packageEntry), "cli.js")],
|
|
218
231
|
};
|
|
219
232
|
}
|