@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 +56 -0
- package/README.md +63 -6
- package/package.json +12 -6
- package/src/config.ts +101 -1
- package/src/external-network-policy.d.mts +8 -0
- package/src/external-network-policy.mjs +27 -0
- package/src/external-runs-view.ts +173 -0
- package/src/external-supervisor.ts +31 -1
- package/src/external-worker-launcher.mjs +22 -15
- package/src/index.ts +66 -13
- package/src/policy.ts +4 -2
- package/src/subagent.ts +97 -0
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
|
|
16
|
-
`pi-auto-review` as a
|
|
17
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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.
|
|
62
|
-
"@types/node": "^
|
|
63
|
-
"pi-subagents": "
|
|
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(
|
|
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,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({
|
|
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((
|
|
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 = (
|
|
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))
|
|
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:
|
|
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 =
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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 (
|