@erichll/pi-sandbox 0.6.2 → 0.8.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 CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-08-18
4
+
5
+ - **Trusted network domain policies.** Persistent `network.allowedDomains` and
6
+ `network.deniedDomains` arrays in the trusted global configuration
7
+ (`~/.pi/agent/extensions/pi-sandbox/config.json`, with legacy fallback) now
8
+ authorize matching connections statically, without the one-shot reviewer.
9
+ Entries use Sandbox Runtime-compatible patterns: exact domains,
10
+ strict-subdomain wildcards such as `*.example.com`, and an optional `:port`;
11
+ a bare `*` (optionally with a port) is accepted only in `deniedDomains`.
12
+ Values are trimmed and deduplicated, and malformed or unknown configuration
13
+ fails closed. Precedence is deterministic — deny, then allow, then the
14
+ existing one-shot reviewer — and empty arrays preserve the previous
15
+ behavior (no persistent network authorization, with every eligible public
16
+ connection reviewed once).
17
+ - **Policy parity across execution surfaces.** The same copied network policy
18
+ is applied to main-agent Bash, built-in subagents, and outer-isolated
19
+ `pi-subagents` workers. External workers receive policy only through their
20
+ capability-bound supervisor registration; missing, malformed, or extra-field
21
+ transport becomes deny-all without disabling dynamic review for valid
22
+ policies.
23
+ - **Exfiltration-risk documentation.** The README now warns that domain
24
+ allowlists are not a complete data-loss-prevention boundary, that
25
+ multi-tenant or user-uploadable destinations (for example `github.com`) can
26
+ themselves be exfiltration channels, and that pi-sandbox never infers or
27
+ silently adds domains.
28
+ - Pin `@anthropic-ai/sandbox-runtime` to `0.0.73` (fixed version; its
29
+ network-policy evaluation semantics are reused by the static allow/deny
30
+ matching).
31
+
32
+ ## 0.7.0 - 2026-08-17
33
+
34
+ - **Builtin `subagent` model fail-fast.** An explicit `model` on `start`/
35
+ `handoff` is now validated against the host model registry before a child Pi
36
+ spawns. Unknown models fail before any session/broker/process is created;
37
+ ambiguous bare ids are rejected with a `provider/model` hint; a trailing
38
+ `:thinking` suffix is preserved; `status`/`wait`/`stop`/`follow_up` are never
39
+ intercepted.
40
+
41
+ - **FleetView external-worker display.** Declare `pi-subagents >=0.50.0` as an
42
+ **optional** peer dependency (no forced install for builtin/off users) and
43
+ mirror pi-sandbox's isolated external workers into pi-subagents' FleetView
44
+ (caller-owned `external-runs`) whenever `subagents.provider ===
45
+ "pi-subagents"` AND `externalWorkerIsolation === "enforce"`. Loading uses a
46
+ dynamic import only; import/registry failures disable the display path
47
+ without weakening fail-closed isolation or network approval. v1 records are
48
+ `running`-only and cleaned up on worker unregister, session shutdown, and
49
+ supervisor replacement.
50
+
51
+ - **`pi-subagents` capability baseline raised to `0.50.0`** (test/CI only;
52
+ does not affect the published runtime). The dev dependency is pinned exactly
53
+ to `0.50.0`, the capability boundary is redocumented, the compatibility-gate
54
+ entry resolves through the package `exports["."]` via `import.meta.resolve`,
55
+ and the external-isolation probe is generalized. Verified by
56
+ `gate:external-isolation` and the model-backed `gate:pi-subagents`
57
+ (`PASS`, `piSubagents: 0.50.0`).
58
+
3
59
  ## 0.6.2 - 2026-08-13
4
60
 
5
61
  - Bump `@erichll/pi-auto-review` to `0.3.5` so consumers inherit the raised
package/README.md CHANGED
@@ -12,9 +12,10 @@ also run complete process-backed subagent trees inside independent sandboxes.
12
12
  writes outside the workspace fail closed. Runtime filesystem denials are not
13
13
  converted into dynamic grants because Sandbox Runtime does not expose a
14
14
  trustworthy filesystem ask callback.
15
- - Network access is denied by default. Each unmatched destination is sent to
16
- `pi-auto-review` as a concrete hostname and port. An approval applies only to
17
- that connection.
15
+ - Network policy is evaluated as static deny, then static allow, then dynamic
16
+ review. Each unmatched public destination is sent to `pi-auto-review` as a
17
+ canonical hostname and concrete port. An approval applies only to that
18
+ connection.
18
19
  - Every Bash command and persistent subagent session owns a separate broker
19
20
  process and Sandbox Runtime manager. Concurrent workers therefore keep
20
21
  independent policy, proxy, approval, and cleanup lifecycles.
@@ -82,12 +83,16 @@ Supported modes:
82
83
  itself is not wrapped while `externalWorkerIsolation` is `off`.
83
84
  - `off`: protect Bash only.
84
85
 
85
- ### `pi-subagents` 0.45.2 capability boundary
86
+ ### `pi-subagents` 0.50.0 capability boundary
86
87
 
87
88
  `pi-sandbox` verifies the following combination in its test suite and release
88
- gate:
89
+ gate. This version boundary is pinned exactly (see the package
90
+ `devDependencies`) and additionally exercised against the latest published
91
+ `pi-subagents` on a nightly CI schedule. The `PI_SUBAGENT_PI_BINARY` seam
92
+ (`externalWorkerIsolation: "enforce"`) is re-checked deterministically by the
93
+ `gate:external-isolation` probe on the pinned and latest dependency.
89
94
 
90
- | Capability | `builtin` | `pi-subagents` 0.45.2 |
95
+ | Capability | `builtin` | `pi-subagents` 0.50.0 |
91
96
  | --- | --- | --- |
92
97
  | Outer worker sandbox | Yes | Opt-in (`externalWorkerIsolation: "enforce"`) |
93
98
  | Bash sandbox | Yes | Yes |
@@ -95,6 +100,11 @@ gate:
95
100
  | `workflowScript`, missions, schedules | No | Yes |
96
101
  | permission-system parent forwarding | Not required | Verified by compatibility gate |
97
102
 
103
+ Upgrading across a capability boundary (for example `0.49.0` → `0.50.0`)
104
+ requires re-running `npm run gate:external-isolation` and, before a release,
105
+ the model-backed `npm run gate:pi-subagents`. See `docs/compat-notes.md` for
106
+ the recorded compatibility seams and how each is verified on upgrade.
107
+
98
108
  For the external provider, `off` leaves the worker process outside the outer
99
109
  Sandbox Runtime boundary. Do not treat inherited Bash sandboxing as complete
100
110
  worker isolation unless the trusted global configuration explicitly enables
@@ -109,6 +119,53 @@ such as `return runs.run('main', { agent, task })`. The compatibility gate's
109
119
  The configuration parser rejects malformed JSON, unknown fields, and unknown
110
120
  providers instead of silently weakening isolation.
111
121
 
122
+ ## Network domain policy
123
+
124
+ Persistent domain authorization is accepted only from the trusted global
125
+ `~/.pi/agent/extensions/pi-sandbox/config.json` (or its legacy trusted fallback
126
+ when the new file is absent). Project configuration cannot override it.
127
+
128
+ ```json
129
+ {
130
+ "network": {
131
+ "allowedDomains": [
132
+ "github.com",
133
+ "*.github.com",
134
+ "registry.npmjs.org:443"
135
+ ],
136
+ "deniedDomains": [
137
+ "uploads.github.com",
138
+ "*:22"
139
+ ]
140
+ }
141
+ }
142
+ ```
143
+
144
+ Entries support exact domains, strict-subdomain wildcards such as
145
+ `*.example.com`, and an optional `:port` restriction. A bare `*` (optionally
146
+ with a port) is accepted only in `deniedDomains`. Values are trimmed and
147
+ deduplicated, and malformed or unknown configuration fails closed.
148
+
149
+ The precedence is deterministic:
150
+
151
+ 1. A matching `deniedDomains` entry rejects the connection.
152
+ 2. Otherwise, a matching `allowedDomains` entry permits it without review.
153
+ 3. Otherwise, the canonical public hostname and port enter the existing
154
+ one-shot auto-review or human approval flow.
155
+
156
+ Both arrays default to empty. That preserves the prior behavior: no persistent
157
+ network authorization, with every eligible public connection reviewed once.
158
+ The same trusted arrays are copied into main-agent Bash, built-in subagents,
159
+ and external `pi-subagents` workers when outer isolation is enforced. Missing
160
+ or malformed external-worker policy transport hard-denies network access.
161
+
162
+ Domain allowlists are not a complete data-loss-prevention boundary. A
163
+ multi-tenant or user-uploadable destination such as `github.com` can itself be
164
+ an exfiltration channel; grant only the narrow domains and ports you intend.
165
+ Real package-manager traffic may require additional hosts—for example, PyPI
166
+ downloads commonly use `files.pythonhosted.org`. `pi-sandbox` never infers or
167
+ silently adds those domains.
168
+
112
169
  To opt in to outer worker isolation for the external provider, use:
113
170
 
114
171
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-sandbox",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Sandbox Runtime-backed Linux and macOS sandbox with pi-auto-review approval",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -50,17 +50,23 @@
50
50
  "test": "node --experimental-strip-types --test test/*.test.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@anthropic-ai/sandbox-runtime": "0.0.71",
53
+ "@anthropic-ai/sandbox-runtime": "0.0.73",
54
54
  "@erichll/pi-auto-review": "0.3.5"
55
55
  },
56
56
  "peerDependencies": {
57
- "@earendil-works/pi-coding-agent": ">=0.82.1",
57
+ "@earendil-works/pi-coding-agent": "^0.84.1",
58
+ "pi-subagents": ">=0.50.0",
58
59
  "typebox": ">=1.0.0"
59
60
  },
61
+ "peerDependenciesMeta": {
62
+ "pi-subagents": {
63
+ "optional": true
64
+ }
65
+ },
60
66
  "devDependencies": {
61
- "@earendil-works/pi-coding-agent": "0.83.0",
62
- "@types/node": "^22.15.3",
63
- "pi-subagents": "^0.47.1",
67
+ "@earendil-works/pi-coding-agent": "^0.84.1",
68
+ "@types/node": "^26.2.0",
69
+ "pi-subagents": "0.50.0",
64
70
  "typescript": "^6.0.3"
65
71
  }
66
72
  }
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join } from "node:path";
4
+ import { NetworkConfigSchema } from "@anthropic-ai/sandbox-runtime";
4
5
 
5
6
  export const SUBAGENT_PROVIDERS = [
6
7
  "builtin",
@@ -19,6 +20,11 @@ export type HostIPCConfig = {
19
20
  retryOnUnixSocketError: boolean;
20
21
  };
21
22
 
23
+ export type NetworkConfig = {
24
+ readonly allowedDomains: readonly string[];
25
+ readonly deniedDomains: readonly string[];
26
+ };
27
+
22
28
  export type PiSandboxConfig = {
23
29
  subagents: {
24
30
  provider: SubagentProvider;
@@ -27,6 +33,7 @@ export type PiSandboxConfig = {
27
33
  filesystem: {
28
34
  additionalAllowRead: readonly string[];
29
35
  };
36
+ network: NetworkConfig;
30
37
  hostIPC: HostIPCConfig;
31
38
  };
32
39
 
@@ -45,6 +52,10 @@ export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freez
45
52
  filesystem: Object.freeze({
46
53
  additionalAllowRead: Object.freeze([]),
47
54
  }),
55
+ network: Object.freeze({
56
+ allowedDomains: Object.freeze([]),
57
+ deniedDomains: Object.freeze([]),
58
+ }),
48
59
  hostIPC: Object.freeze({
49
60
  mode: "off",
50
61
  preflightCommandPrefixes: Object.freeze([]),
@@ -86,11 +97,40 @@ function rejectUnknownKeys(
86
97
  }
87
98
  }
88
99
 
100
+ function hasValidDomainLabels(
101
+ pattern: string,
102
+ allowDenyAll: boolean,
103
+ ): boolean {
104
+ const portMatch = pattern.match(/:([1-9][0-9]{0,4})$/);
105
+ const hostPattern = portMatch
106
+ ? pattern.slice(0, -portMatch[0].length)
107
+ : pattern;
108
+ if (portMatch && Number(portMatch[1]) > 65_535) return false;
109
+ if (hostPattern === "*") return allowDenyAll;
110
+ const hostname = hostPattern.startsWith("*.")
111
+ ? hostPattern.slice(2)
112
+ : hostPattern;
113
+ return (
114
+ hostname.length <= 253 &&
115
+ hostname.includes(".") &&
116
+ hostname.split(".").every(
117
+ (label) =>
118
+ label.length > 0 &&
119
+ label.length <= 63 &&
120
+ /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label),
121
+ )
122
+ );
123
+ }
124
+
89
125
  export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
90
126
  if (!isRecord(value)) {
91
127
  throw new Error("invalid pi-sandbox configuration: root must be an object");
92
128
  }
93
- rejectUnknownKeys(value, ["subagents", "filesystem", "hostIPC"], "root");
129
+ rejectUnknownKeys(
130
+ value,
131
+ ["subagents", "filesystem", "network", "hostIPC"],
132
+ "root",
133
+ );
94
134
 
95
135
  if (value.subagents !== undefined && !isRecord(value.subagents)) {
96
136
  throw new Error(
@@ -154,6 +194,58 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
154
194
  );
155
195
  }
156
196
 
197
+ if (value.network !== undefined && !isRecord(value.network)) {
198
+ throw new Error(
199
+ "invalid pi-sandbox configuration: network must be an object",
200
+ );
201
+ }
202
+ const network = value.network ?? {};
203
+ rejectUnknownKeys(
204
+ network,
205
+ ["allowedDomains", "deniedDomains"],
206
+ "network",
207
+ );
208
+ const normalizeDomainList = (
209
+ key: "allowedDomains" | "deniedDomains",
210
+ ): string[] => {
211
+ const configured = network[key] ?? DEFAULT_PI_SANDBOX_CONFIG.network[key];
212
+ if (
213
+ !Array.isArray(configured) ||
214
+ configured.some(
215
+ (pattern) => typeof pattern !== "string" || pattern.trim() === "",
216
+ )
217
+ ) {
218
+ throw new Error(
219
+ `invalid pi-sandbox configuration: network.${key} must be an array of non-empty strings`,
220
+ );
221
+ }
222
+ const normalized = [
223
+ ...new Set(configured.map((pattern) => pattern.trim())),
224
+ ];
225
+ const invalidIndex = normalized.findIndex(
226
+ (pattern) => !hasValidDomainLabels(pattern, key === "deniedDomains"),
227
+ );
228
+ if (invalidIndex >= 0) {
229
+ throw new Error(
230
+ `invalid pi-sandbox configuration: network.${key}[${invalidIndex}] contains an invalid domain pattern`,
231
+ );
232
+ }
233
+ const candidate = {
234
+ allowedDomains: key === "allowedDomains" ? normalized : [],
235
+ deniedDomains: key === "deniedDomains" ? normalized : [],
236
+ };
237
+ const result = NetworkConfigSchema.safeParse(candidate);
238
+ if (!result.success) {
239
+ const issue = result.error.issues[0];
240
+ throw new Error(
241
+ `invalid pi-sandbox configuration: network.${key}${typeof issue?.path[1] === "number" ? `[${issue.path[1]}]` : ""} ${issue?.message ?? "contains an invalid domain pattern"}`,
242
+ );
243
+ }
244
+ return normalized;
245
+ };
246
+ const allowedDomains = normalizeDomainList("allowedDomains");
247
+ const deniedDomains = normalizeDomainList("deniedDomains");
248
+
157
249
  if (value.hostIPC !== undefined && !isRecord(value.hostIPC)) {
158
250
  throw new Error(
159
251
  "invalid pi-sandbox configuration: hostIPC must be an object",
@@ -204,6 +296,10 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
204
296
  filesystem: {
205
297
  additionalAllowRead: [...new Set(additionalAllowRead)],
206
298
  },
299
+ network: {
300
+ allowedDomains: [...allowedDomains],
301
+ deniedDomains: [...deniedDomains],
302
+ },
207
303
  hostIPC: {
208
304
  mode: hostIPCMode as HostIPCMode,
209
305
  preflightCommandPrefixes: [
@@ -226,6 +322,10 @@ function defaultPiSandboxConfig(): PiSandboxConfig {
226
322
  ...DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead,
227
323
  ],
228
324
  },
325
+ network: {
326
+ allowedDomains: [...DEFAULT_PI_SANDBOX_CONFIG.network.allowedDomains],
327
+ deniedDomains: [...DEFAULT_PI_SANDBOX_CONFIG.network.deniedDomains],
328
+ },
229
329
  hostIPC: {
230
330
  mode: DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode,
231
331
  preflightCommandPrefixes: [
@@ -0,0 +1,8 @@
1
+ export type ExternalNetworkPolicy = {
2
+ allowedDomains: string[];
3
+ deniedDomains: string[];
4
+ };
5
+
6
+ export function decodeExternalNetworkPolicy(
7
+ value: unknown,
8
+ ): ExternalNetworkPolicy;
@@ -0,0 +1,27 @@
1
+ import { NetworkConfigSchema } from "@anthropic-ai/sandbox-runtime";
2
+
3
+ /**
4
+ * Decode the supervisor-owned network policy delivered during registration.
5
+ * Any missing, extra, or malformed data becomes a deny-all policy so a broken
6
+ * transport can never widen an external worker's access.
7
+ */
8
+ export function decodeExternalNetworkPolicy(value) {
9
+ if (
10
+ !value ||
11
+ typeof value !== "object" ||
12
+ Array.isArray(value) ||
13
+ Object.keys(value).some(
14
+ (key) => key !== "allowedDomains" && key !== "deniedDomains",
15
+ )
16
+ ) {
17
+ return { allowedDomains: [], deniedDomains: ["*"] };
18
+ }
19
+ const result = NetworkConfigSchema.safeParse(value);
20
+ if (!result.success) {
21
+ return { allowedDomains: [], deniedDomains: ["*"] };
22
+ }
23
+ return {
24
+ allowedDomains: [...result.data.allowedDomains],
25
+ deniedDomains: [...result.data.deniedDomains],
26
+ };
27
+ }
@@ -0,0 +1,173 @@
1
+ // Optional FleetView integration for pi-sandbox's isolated external workers.
2
+ //
3
+ // When the host is running the external `pi-subagents` provider with
4
+ // `externalWorkerIsolation: "enforce"`, pi-sandbox supervises the external
5
+ // worker process trees itself. This module mirrors those supervised workers
6
+ // into pi-subagents' caller-owned external-run registry so they show up in the
7
+ // FleetView (pi-subagents >= 0.50.0).
8
+ //
9
+ // This is a pure observability enhancement: it participates in NO sandbox
10
+ // allow/deny decision, and nothing here can downgrade an isolated worker into
11
+ // host execution. `pi-subagents/external-runs` is loaded via a DYNAMIC import
12
+ // only (never a top-level static import), because pi-subagents is an OPTIONAL
13
+ // peer dependency: builtin/off deployments may not have it installed.
14
+ //
15
+ // Deliberate scope of v1: records are "running" only. No completed/failed/
16
+ // endedAt are fabricated because neither the launcher nor the supervisor
17
+ // protocol currently reports process exit evidence to this extension (the
18
+ // record would therefore be a lie). See docs/compat-notes.md seam 5.
19
+
20
+ type ExternalRunState = "running";
21
+
22
+ /** Structural view of the specific pi-subagents API we consume. */
23
+ type ExternalRunsApi = {
24
+ registerExternalRun(input: {
25
+ id: string;
26
+ sessionId: string;
27
+ source: string;
28
+ label: string;
29
+ state: ExternalRunState;
30
+ startedAt: number;
31
+ preview?: string;
32
+ }): unknown;
33
+ unregisterExternalRun(sessionId: string, id: string): boolean;
34
+ };
35
+
36
+ export type ExternalRunsView = {
37
+ /** Whether the pi-subagents runtime is reachable (dynamic import succeeded). */
38
+ readonly enabled: boolean;
39
+ /** Diagnostic string when the runtime is disabled. */
40
+ readonly error?: string;
41
+ /** Mirror a supervisor lifecycle.registered event into the registry. */
42
+ registered(worker: { id: string; cwd: string; startedAt: number }): void;
43
+ /** Mirror a supervisor lifecycle.unregistered event out of the registry. */
44
+ unregistered(workerId: string): void;
45
+ /** Unregister every run this view registered (session shutdown / replace). */
46
+ close(): void;
47
+ };
48
+
49
+ const VIEW_LABEL = "isolated pi-subagents worker";
50
+ const VIEW_SOURCE = "pi-sandbox";
51
+
52
+ /**
53
+ * Whether the FleetView integration should be enabled for a given provider /
54
+ * isolation combination. It is enabled ONLY when the external pi-subagents
55
+ * provider owns orchestration AND outer worker isolation is enforced -- the
56
+ * sole case where pi-sandbox actually supervises external workers and can
57
+ * truthfully vouch for a "running" record it owns.
58
+ */
59
+ export function externalRunsViewEnabledWhen(
60
+ provider: string,
61
+ externalWorkerIsolation: string,
62
+ ): boolean {
63
+ return provider === "pi-subagents" && externalWorkerIsolation === "enforce";
64
+ }
65
+
66
+ function ident(value: string): { ok: true; value: string } | { ok: false; error: string } {
67
+ const trimmed = value.trim();
68
+ if (!trimmed || trimmed !== value) {
69
+ return { ok: false, error: "must be a non-empty trimmed string" };
70
+ }
71
+ return { ok: true, value: trimmed };
72
+ }
73
+
74
+ export function createExternalRunsView(
75
+ sessionIdInput: string,
76
+ load: () => Promise<unknown> = () => import("pi-subagents/external-runs"),
77
+ ): Promise<ExternalRunsView> {
78
+ return (async () => {
79
+ const sessionId = ident(sessionIdInput);
80
+ if (!sessionId.ok) {
81
+ return disabledView(`invalid session id: ${sessionId.error}`);
82
+ }
83
+ let api: ExternalRunsApi | undefined;
84
+ let loadError: string | undefined;
85
+ try {
86
+ const mod = (await load()) as Record<string, unknown>;
87
+ const registerExternalRun = mod.registerExternalRun;
88
+ const unregisterExternalRun = mod.unregisterExternalRun;
89
+ if (typeof registerExternalRun !== "function" || typeof unregisterExternalRun !== "function") {
90
+ throw new Error("pi-subagents/external-runs does not expose registerExternalRun/unregisterExternalRun");
91
+ }
92
+ api = { registerExternalRun, unregisterExternalRun } as unknown as ExternalRunsApi;
93
+ } catch (error) {
94
+ loadError = `FleetView external-run registration unavailable: ${error instanceof Error ? error.message : String(error)}`;
95
+ // Never thrown: disable only the display path. Sandbox fail-closed and
96
+ // network approval for the supervisor are untouched.
97
+ return disabledView(loadError);
98
+ }
99
+
100
+ // (sessionId, workerId) pairs this extension registered, so we never
101
+ // duplicate-register and never unregister records owned by other sources.
102
+ const registered = new Map<string, number>();
103
+
104
+ return {
105
+ get enabled() {
106
+ return Boolean(api);
107
+ },
108
+ error: loadError,
109
+ registered(worker) {
110
+ const fullId = ident(worker.id);
111
+ const cwd = ident(worker.cwd);
112
+ if (!api || !fullId.ok || !cwd.ok) return;
113
+ if (registered.has(fullId.value)) return; // avoid duplicate registration
114
+ try {
115
+ api!.registerExternalRun({
116
+ id: fullId.value,
117
+ sessionId: sessionId.value,
118
+ source: VIEW_SOURCE,
119
+ label: VIEW_LABEL,
120
+ state: "running",
121
+ startedAt:
122
+ Number.isSafeInteger(worker.startedAt) && worker.startedAt > 0
123
+ ? worker.startedAt
124
+ : Date.now(),
125
+ preview: cwd.value,
126
+ });
127
+ registered.set(fullId.value, Date.now());
128
+ } catch (error) {
129
+ // Registry full / malformed record / unknown fields: log, do not emit.
130
+ console.error(
131
+ `pi-sandbox: could not register external run for worker '${fullId.value}': ${error instanceof Error ? error.message : String(error)}`,
132
+ );
133
+ }
134
+ },
135
+ unregistered(workerId) {
136
+ const fullId = ident(workerId);
137
+ if (!fullId.ok) return;
138
+ if (!registered.has(fullId.value)) return; // we did not register it
139
+ registered.delete(fullId.value);
140
+ if (api) {
141
+ try {
142
+ api.unregisterExternalRun(sessionId.value, fullId.value);
143
+ } catch {
144
+ // Best-effort; nothing else to fall back to.
145
+ }
146
+ }
147
+ },
148
+ close() {
149
+ for (const workerId of registered.keys()) {
150
+ if (api) {
151
+ try {
152
+ api.unregisterExternalRun(sessionId.value, workerId);
153
+ } catch {
154
+ // best-effort
155
+ }
156
+ }
157
+ }
158
+ registered.clear();
159
+ },
160
+ };
161
+ })();
162
+ }
163
+
164
+ function disabledView(error: string): ExternalRunsView {
165
+ console.error(`pi-sandbox: ${error}`);
166
+ return {
167
+ enabled: false,
168
+ error,
169
+ registered() {},
170
+ unregistered() {},
171
+ close() {},
172
+ };
173
+ }
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import type { NetworkEndpoint, TrapApprovalContext } from "./approval.ts";
7
7
  import { approveDomainEndpoint } from "./approval.ts";
8
+ import type { NetworkConfig } from "./config.ts";
8
9
 
9
10
  const MAX_FRAME_BYTES = 16 * 1024;
10
11
  const PROTOCOL_VERSION = 1;
@@ -20,6 +21,13 @@ type Request = {
20
21
  port?: unknown;
21
22
  };
22
23
 
24
+ export type ExternalWorkerSupervisorLifecycle = {
25
+ /** Called immediately after a worker finishes the register protocol. */
26
+ registered?(worker: { id: string; cwd: string; startedAt: number }): void;
27
+ /** Called immediately after a worker completes the unregister protocol. */
28
+ unregistered?(worker: { id: string; cwd: string }): void;
29
+ };
30
+
23
31
  export type ExternalWorkerSupervisor = {
24
32
  socketPath: string;
25
33
  capability: string;
@@ -35,7 +43,13 @@ export type ExternalWorkerSupervisor = {
35
43
 
36
44
  export async function createExternalWorkerSupervisor(
37
45
  approvalContext: (worker: { id: string; cwd: string }) => TrapApprovalContext,
46
+ lifecycle: ExternalWorkerSupervisorLifecycle = {},
47
+ network: NetworkConfig = { allowedDomains: [], deniedDomains: [] },
38
48
  ): Promise<ExternalWorkerSupervisor> {
49
+ const trustedNetwork = {
50
+ allowedDomains: [...network.allowedDomains],
51
+ deniedDomains: [...network.deniedDomains],
52
+ };
39
53
  const directory = await mkdtemp(join(tmpdir(), "pi-sandbox-external-"));
40
54
  await chmod(directory, 0o700);
41
55
  const socketPath = join(directory, "supervisor.sock");
@@ -93,7 +107,19 @@ export async function createExternalWorkerSupervisor(
93
107
  startedAt: Date.now(),
94
108
  requests: 0,
95
109
  });
96
- socket.write(`${JSON.stringify({ id: request.id, action: "allow" })}\n`);
110
+ socket.write(`${JSON.stringify({
111
+ id: request.id,
112
+ action: "allow",
113
+ network: {
114
+ allowedDomains: [...trustedNetwork.allowedDomains],
115
+ deniedDomains: [...trustedNetwork.deniedDomains],
116
+ },
117
+ })}\n`);
118
+ lifecycle.registered?.({
119
+ id: request.workerId as string,
120
+ cwd: request.cwd as string,
121
+ startedAt: workers.get(request.workerId as string)!.startedAt,
122
+ });
97
123
  return;
98
124
  }
99
125
  const knownWorker =
@@ -106,6 +132,10 @@ export async function createExternalWorkerSupervisor(
106
132
  seen.add(request.id as string);
107
133
  knownWorker.state = "exited";
108
134
  socket.write(`${JSON.stringify({ id: request.id, action: "allow" })}\n`);
135
+ lifecycle.unregistered?.({
136
+ id: request.workerId as string,
137
+ cwd: request.cwd as string,
138
+ });
109
139
  return;
110
140
  }
111
141
  const workerCwd =
@@ -8,6 +8,7 @@ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync,
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { dirname, join, parse, resolve } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { decodeExternalNetworkPolicy } from "./external-network-policy.mjs";
11
12
 
12
13
  const realPi = process.env.PI_SANDBOX_EXTERNAL_REAL_PI_BINARY;
13
14
  const workerId = randomUUID();
@@ -32,19 +33,22 @@ function worktreeGitReadPaths(cwd) {
32
33
  function supervisorRequest(payload) {
33
34
  const socketPath = process.env.PI_SANDBOX_EXTERNAL_SUPERVISOR_SOCKET;
34
35
  const capability = process.env.PI_SANDBOX_EXTERNAL_SUPERVISOR_CAPABILITY;
35
- if (!socketPath || !capability) return Promise.resolve("deny");
36
- return new Promise((resolveAction) => {
36
+ if (!socketPath || !capability) return Promise.resolve({ action: "deny" });
37
+ return new Promise((resolveResponse) => {
37
38
  const socket = createConnection(socketPath);
38
39
  let buffer = "";
39
- const finish = (action) => { socket.destroy(); resolveAction(action === "allow" ? "allow" : "deny"); };
40
+ const finish = (response) => {
41
+ socket.destroy();
42
+ resolveResponse(response?.action === "allow" ? response : { action: "deny" });
43
+ };
40
44
  socket.setEncoding("utf8");
41
- socket.setTimeout(30_000, () => finish("deny"));
42
- socket.once("error", () => finish("deny"));
45
+ socket.setTimeout(30_000, () => finish({ action: "deny" }));
46
+ socket.once("error", () => finish({ action: "deny" }));
43
47
  socket.on("data", (chunk) => {
44
48
  buffer += chunk;
45
49
  const newline = buffer.indexOf("\n");
46
50
  if (newline < 0 || Buffer.byteLength(buffer, "utf8") > 16 * 1024) return;
47
- try { finish(JSON.parse(buffer.slice(0, newline)).action); } catch { finish("deny"); }
51
+ try { finish(JSON.parse(buffer.slice(0, newline))); } catch { finish({ action: "deny" }); }
48
52
  });
49
53
  socket.once("connect", () => socket.write(`${JSON.stringify({
50
54
  version: 1, capability, id: randomUUID(), ...payload,
@@ -55,10 +59,10 @@ function registerWorker() {
55
59
  return supervisorRequest({ type: "register", workerId, cwd: process.cwd() });
56
60
  }
57
61
  function unregisterWorker() {
58
- return supervisorRequest({ type: "unregister", workerId, cwd: process.cwd() });
62
+ return supervisorRequest({ type: "unregister", workerId, cwd: process.cwd() }).then((response) => response.action);
59
63
  }
60
64
  function askSupervisor(hostname, port) {
61
- return supervisorRequest({ type: "network", workerId, cwd: process.cwd(), hostname, port });
65
+ return supervisorRequest({ type: "network", workerId, cwd: process.cwd(), hostname, port }).then((response) => response.action);
62
66
  }
63
67
 
64
68
  function createMandatoryDenyPlaceholders(cwd, agentDir) {
@@ -174,6 +178,13 @@ if (!realPi) {
174
178
  join(agentDir, "sandbox.json"),
175
179
  join(agentDir, "extensions"),
176
180
  ];
181
+ const registration = await registerWorker();
182
+ if (registration.action !== "allow") {
183
+ process.stderr.write("pi-sandbox: external worker registration was denied\n");
184
+ process.exitCode = 1;
185
+ process.exit();
186
+ }
187
+ const registeredNetwork = decodeExternalNetworkPolicy(registration.network);
177
188
  const runtimeConfig = {
178
189
  filesystem: {
179
190
  denyRead: home === parse(home).root ? [] : [home],
@@ -200,16 +211,12 @@ if (!realPi) {
200
211
  allowGitConfig: true,
201
212
  },
202
213
  network: {
203
- allowedDomains: [], deniedDomains: [], allowLocalBinding: false,
214
+ allowedDomains: registeredNetwork.allowedDomains,
215
+ deniedDomains: registeredNetwork.deniedDomains,
204
216
  allowAllUnixSockets: false, allowUnixSockets: [],
217
+ allowLocalBinding: false,
205
218
  },
206
219
  };
207
- const registration = await registerWorker();
208
- if (registration !== "allow") {
209
- process.stderr.write("pi-sandbox: external worker registration was denied\n");
210
- process.exitCode = 1;
211
- process.exit();
212
- }
213
220
  const broker = fork(new URL("./srt-broker.mjs", import.meta.url), [], {
214
221
  cwd,
215
222
  detached: true,
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  loadPiSandboxConfig,
21
21
  type HostIPCConfig,
22
+ type NetworkConfig,
22
23
  type SubagentProvider,
23
24
  } from "./config.ts";
24
25
  import { runCommandWithHostIPC } from "./host-ipc.ts";
@@ -31,11 +32,17 @@ import type {
31
32
  ProcessBackedSubagentManager,
32
33
  ProcessBackedSubagentSession,
33
34
  } from "./subagent.ts";
35
+ import { validateSubagentModel } from "./subagent.ts";
34
36
  import { Type } from "typebox";
35
37
  import {
36
38
  createExternalWorkerSupervisor,
37
39
  type ExternalWorkerSupervisor,
38
40
  } from "./external-supervisor.ts";
41
+ import {
42
+ createExternalRunsView,
43
+ externalRunsViewEnabledWhen,
44
+ type ExternalRunsView,
45
+ } from "./external-runs-view.ts";
39
46
 
40
47
  const EXTENSION_NAME = "pi-sandbox";
41
48
  const registrations = new WeakMap<ExtensionAPI, Promise<void>>();
@@ -165,6 +172,7 @@ function sandboxOperations(
165
172
  ctx: ExtensionContext,
166
173
  turnIndex: () => number,
167
174
  additionalAllowRead: readonly string[],
175
+ network: NetworkConfig,
168
176
  hostIPC: HostIPCConfig,
169
177
  sandbox?: PiSandboxExtensionOptions["sandbox"],
170
178
  ): BashOperations {
@@ -216,7 +224,7 @@ function sandboxOperations(
216
224
  onData: options.onData,
217
225
  onStderr,
218
226
  shellPath,
219
- policy: createDefaultPolicy(cwd, { additionalAllowRead }),
227
+ policy: createDefaultPolicy(cwd, { additionalAllowRead, network }),
220
228
  review: async (trap) => {
221
229
  const result = await approveSandboxTrap(trap, approvalContext);
222
230
  if (result.action === "deny" && result.reason) {
@@ -254,7 +262,12 @@ async function performRegistration(
254
262
  const subagentProvider =
255
263
  options.subagentProvider ?? config.subagents.provider;
256
264
  const externalWorkerIsolation = config.subagents.externalWorkerIsolation;
265
+ const externalRunsViewEnabled = externalRunsViewEnabledWhen(
266
+ subagentProvider,
267
+ externalWorkerIsolation,
268
+ );
257
269
  const additionalAllowRead = config.filesystem.additionalAllowRead;
270
+ const network = config.network;
258
271
  const hostIPC = options.hostIPC ?? config.hostIPC;
259
272
  const subagents =
260
273
  subagentProvider === "builtin"
@@ -268,6 +281,7 @@ async function performRegistration(
268
281
  const cwd = process.cwd();
269
282
  let isolation: ExternalWorkerIsolation | undefined;
270
283
  let supervisor: ExternalWorkerSupervisor | undefined;
284
+ let fleetView: ExternalRunsView | undefined;
271
285
  let externalIsolationFailure: string | undefined;
272
286
  const localBash = createBashToolDefinition(cwd);
273
287
 
@@ -281,6 +295,7 @@ async function performRegistration(
281
295
  ctx,
282
296
  () => currentTurn,
283
297
  additionalAllowRead,
298
+ network,
284
299
  hostIPC,
285
300
  options.sandbox,
286
301
  ),
@@ -369,7 +384,7 @@ async function performRegistration(
369
384
  cwd: ctx.cwd,
370
385
  model: params.model,
371
386
  tools: pi.getActiveTools().filter((name) => name !== "subagent"),
372
- policy: createDefaultPolicy(ctx.cwd, { additionalAllowRead }),
387
+ policy: createDefaultPolicy(ctx.cwd, { additionalAllowRead, network }),
373
388
  sandbox: options.sandbox,
374
389
  review: async (trap: Parameters<typeof approveSandboxTrap>[0]) =>
375
390
  (await approveSandboxTrap(trap, approvalContext)).action,
@@ -474,6 +489,21 @@ async function performRegistration(
474
489
  : requireTask();
475
490
  const parentId =
476
491
  action === "handoff" ? requireSession().id : undefined;
492
+ // Fail fast on an invalid explicit model before spawning a child Pi.
493
+ // Only process-spawning actions (start/handoff) reach this block, so
494
+ // status/wait/stop/follow_up are never intercepted by model validation.
495
+ // When no model is specified the child inherits host behavior (no check).
496
+ const modelSpec = params.model?.trim();
497
+ if (modelSpec) {
498
+ const modelCheck = validateSubagentModel(
499
+ modelSpec,
500
+ ctx.modelRegistry?.getAvailable() ?? [],
501
+ ctx.model?.provider,
502
+ );
503
+ if (!modelCheck.ok) {
504
+ throw new Error(modelCheck.error);
505
+ }
506
+ }
477
507
  const session = await subagents.start(sessionOptions(task, parentId));
478
508
  const unsubscribe = update(session);
479
509
  try {
@@ -520,6 +550,7 @@ async function performRegistration(
520
550
  ctx,
521
551
  () => currentTurn,
522
552
  additionalAllowRead,
553
+ network,
523
554
  hostIPC,
524
555
  options.sandbox,
525
556
  ),
@@ -548,6 +579,10 @@ async function performRegistration(
548
579
  });
549
580
 
550
581
  pi.on("session_start", async (_event, ctx) => {
582
+ // A new session may replace a prior supervisor ownership; drop the old
583
+ // session's FleetView registrations before reconstructing.
584
+ fleetView?.close();
585
+ fleetView = undefined;
551
586
  if (
552
587
  subagentProvider === "pi-subagents" &&
553
588
  externalWorkerIsolation === "enforce"
@@ -555,15 +590,29 @@ async function performRegistration(
555
590
  try {
556
591
  isolation?.restore();
557
592
  await supervisor?.close();
558
- supervisor = await createExternalWorkerSupervisor((worker) => ({
559
- broker: getBoundaryBroker(),
560
- command: "external pi-subagents worker network request",
561
- cwd: worker.cwd,
562
- sessionId: sessionId(ctx),
563
- scopeKey: `${sessionId(ctx)}:turn:${currentTurn}`,
564
- agentName: `pi-subagents-worker:${worker.id}`,
565
- humanApproval: humanApproval(ctx),
566
- }));
593
+ supervisor = undefined;
594
+ if (externalRunsViewEnabled) {
595
+ fleetView = await createExternalRunsView(sessionId(ctx));
596
+ }
597
+ supervisor = await createExternalWorkerSupervisor(
598
+ (worker) => ({
599
+ broker: getBoundaryBroker(),
600
+ command: "external pi-subagents worker network request",
601
+ cwd: worker.cwd,
602
+ sessionId: sessionId(ctx),
603
+ scopeKey: `${sessionId(ctx)}:turn:${currentTurn}`,
604
+ agentName: `pi-subagents-worker:${worker.id}`,
605
+ humanApproval: humanApproval(ctx),
606
+ }),
607
+ {
608
+ // Observability-only: never influences allow/deny, and a failure
609
+ // here must not stop the supervisor from answering approval RPCs
610
+ // or make the worker launch on the host.
611
+ registered: (worker) => fleetView?.registered(worker),
612
+ unregistered: (worker) => fleetView?.unregistered(worker.id),
613
+ },
614
+ network,
615
+ );
567
616
  isolation = enableExternalWorkerIsolation(supervisor);
568
617
  externalIsolationFailure = undefined;
569
618
  } catch (error) {
@@ -573,6 +622,8 @@ async function performRegistration(
573
622
  isolation = undefined;
574
623
  await supervisor?.close();
575
624
  supervisor = undefined;
625
+ fleetView?.close();
626
+ fleetView = undefined;
576
627
  console.error(message);
577
628
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
578
629
  }
@@ -612,8 +663,8 @@ async function performRegistration(
612
663
  if (ctx.hasUI) {
613
664
  ctx.ui.notify(
614
665
  process.platform === "darwin"
615
- ? `${EXTENSION_NAME} enabled: macOS Sandbox Runtime with static filesystem policy and per-connection network review`
616
- : `${EXTENSION_NAME} enabled: Linux Sandbox Runtime with static filesystem policy and per-connection network review`,
666
+ ? `${EXTENSION_NAME} enabled: macOS Sandbox Runtime with static filesystem/domain policy and per-connection network review`
667
+ : `${EXTENSION_NAME} enabled: Linux Sandbox Runtime with static filesystem/domain policy and per-connection network review`,
617
668
  "info",
618
669
  );
619
670
  }
@@ -622,6 +673,8 @@ async function performRegistration(
622
673
  pi.on("session_shutdown", async () => {
623
674
  isolation?.restore();
624
675
  isolation = undefined;
676
+ fleetView?.close();
677
+ fleetView = undefined;
625
678
  await supervisor?.close();
626
679
  supervisor = undefined;
627
680
  await subagents?.shutdown();
package/src/policy.ts CHANGED
@@ -10,6 +10,7 @@ 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 type { NetworkConfig } from "./config.ts";
13
14
 
14
15
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
15
16
  const NODE_INSTALL_ROOT = dirname(dirname(process.execPath));
@@ -120,6 +121,7 @@ export type SandboxPolicy = {
120
121
 
121
122
  export type CreateDefaultPolicyOptions = {
122
123
  additionalAllowRead?: readonly string[];
124
+ network?: NetworkConfig;
123
125
  };
124
126
 
125
127
  export function isSecretDenyWriteBasename(name: string): boolean {
@@ -271,8 +273,8 @@ export function createDefaultPolicy(
271
273
  ],
272
274
  },
273
275
  network: {
274
- allowedDomains: [],
275
- deniedDomains: [],
276
+ allowedDomains: [...(options.network?.allowedDomains ?? [])],
277
+ deniedDomains: [...(options.network?.deniedDomains ?? [])],
276
278
  allowLocalBinding: false,
277
279
  allowAllUnixSockets: false,
278
280
  allowUnixSockets: [],
package/src/subagent.ts CHANGED
@@ -104,6 +104,103 @@ function shellQuote(value: string): string {
104
104
  return `'${value.replace(/'/g, `'\\''`)}'`;
105
105
  }
106
106
 
107
+ /**
108
+ * Minimal structural shape of a registry model used for validation. Kept as
109
+ * a structural type so this pure helper does not depend on the host coding
110
+ * agent's `Model` type.
111
+ */
112
+ export interface SubagentModelLike {
113
+ /** Bare model id, e.g. `claude-3-5-sonnet` (not provider-prefixed). */
114
+ id: string;
115
+ /** Provider id, e.g. `anthropic`. */
116
+ provider: string;
117
+ }
118
+
119
+ export type SubagentModelValidation =
120
+ | { ok: true; mode: "inherit" }
121
+ | { ok: true; mode: "explicit" }
122
+ | { ok: false; error: string };
123
+
124
+ // Mirrors pi's valid thinking levels (cli/args.ts VALID_THINKING_LEVELS).
125
+ const VALID_THINKING_LEVELS = new Set([
126
+ "off",
127
+ "minimal",
128
+ "low",
129
+ "medium",
130
+ "high",
131
+ "xhigh",
132
+ "max",
133
+ ]);
134
+
135
+ /**
136
+ * Split an optional trailing `:thinking` suffix off a model pattern. A suffix
137
+ * that is not a valid thinking level is treated as part of the model id (pi
138
+ * supports model ids containing colons, e.g. OpenRouter's `:exacto`).
139
+ */
140
+ export function splitModelThinking(
141
+ modelSpec: string,
142
+ ): { base: string; thinking?: string } {
143
+ const colon = modelSpec.lastIndexOf(":");
144
+ if (colon === -1) return { base: modelSpec };
145
+ const suffix = modelSpec.slice(colon + 1);
146
+ if (VALID_THINKING_LEVELS.has(suffix)) {
147
+ return { base: modelSpec.slice(0, colon), thinking: suffix };
148
+ }
149
+ return { base: modelSpec };
150
+ }
151
+
152
+ /**
153
+ * Fail-fast validation of an explicit subagent model before a child Pi spawns.
154
+ *
155
+ * Rules (see docs/compat-notes.md / Plan.md phase C2):
156
+ * - `undefined`/empty -> inherit host behavior, never validated.
157
+ * - `provider/model` -> requires an exact match on `${provider}/${modelId}`.
158
+ * - bare model id -> preferred provider first; otherwise accept only when
159
+ * globally unique; otherwise reject as ambiguous and ask for the full id.
160
+ * - a valid trailing `:thinking` suffix is stripped for base validation and
161
+ * preserved in the original pattern passed to the child.
162
+ *
163
+ * Only `start`/`handoff` (process-spawning actions) should call this.
164
+ */
165
+ export function validateSubagentModel(
166
+ modelSpec: string | undefined,
167
+ available: readonly SubagentModelLike[],
168
+ preferredProvider: string | undefined,
169
+ ): SubagentModelValidation {
170
+ const spec = (modelSpec ?? "").trim();
171
+ if (!spec) return { ok: true, mode: "inherit" };
172
+ const { base } = splitModelThinking(spec);
173
+
174
+ // Full `provider/model` reference: require an exact registry match.
175
+ const fullMatch = available.find((m) => `${m.provider}/${m.id}` === base);
176
+ if (fullMatch) return { ok: true, mode: "explicit" };
177
+
178
+ // Contains a `/`, so it was intended as a full provider/model reference that
179
+ // did not match any registered model. Do not fall back to bare matching.
180
+ if (base.includes("/")) {
181
+ return {
182
+ ok: false,
183
+ error: `subagent start requires an available model; unknown model '${base}'`, // eslint-disable-line
184
+ };
185
+ }
186
+
187
+ // Bare model id.
188
+ const matches = available.filter((m) => m.id === base);
189
+ if (matches.length === 0) {
190
+ return {
191
+ ok: false,
192
+ error: `subagent start requires an available model; unknown model '${base}'`, // eslint-disable-line
193
+ };
194
+ }
195
+ const preferred = matches.find((m) => m.provider === preferredProvider);
196
+ if (preferred) return { ok: true, mode: "explicit" };
197
+ if (matches.length === 1) return { ok: true, mode: "explicit" };
198
+ return {
199
+ ok: false,
200
+ error: `subagent start requires an unambiguous model; '${base}' is available under multiple providers (${matches.map((m) => m.provider).join(", ")}). Use provider/model or set PI_MODEL to disambiguate.`,
201
+ };
202
+ }
203
+
107
204
  export function resolvePiInvocation(): ProcessInvocation {
108
205
  const currentEntry = process.argv[1];
109
206
  if (