@erichll/pi-sandbox 0.7.0 → 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 +29 -0
- package/README.md +51 -3
- package/package.json +2 -2
- 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-supervisor.ts +14 -1
- package/src/external-worker-launcher.mjs +22 -15
- package/src/index.ts +10 -4
- package/src/policy.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
3
32
|
## 0.7.0 - 2026-08-17
|
|
4
33
|
|
|
5
34
|
- **Builtin `subagent` model fail-fast.** An explicit `model` on `start`/
|
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.
|
|
@@ -118,6 +119,53 @@ such as `return runs.run('main', { agent, task })`. The compatibility gate's
|
|
|
118
119
|
The configuration parser rejects malformed JSON, unknown fields, and unknown
|
|
119
120
|
providers instead of silently weakening isolation.
|
|
120
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
|
+
|
|
121
169
|
To opt in to outer worker isolation for the external provider, use:
|
|
122
170
|
|
|
123
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,7 +50,7 @@
|
|
|
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": {
|
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
|
+
}
|
|
@@ -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;
|
|
@@ -43,7 +44,12 @@ export type ExternalWorkerSupervisor = {
|
|
|
43
44
|
export async function createExternalWorkerSupervisor(
|
|
44
45
|
approvalContext: (worker: { id: string; cwd: string }) => TrapApprovalContext,
|
|
45
46
|
lifecycle: ExternalWorkerSupervisorLifecycle = {},
|
|
47
|
+
network: NetworkConfig = { allowedDomains: [], deniedDomains: [] },
|
|
46
48
|
): Promise<ExternalWorkerSupervisor> {
|
|
49
|
+
const trustedNetwork = {
|
|
50
|
+
allowedDomains: [...network.allowedDomains],
|
|
51
|
+
deniedDomains: [...network.deniedDomains],
|
|
52
|
+
};
|
|
47
53
|
const directory = await mkdtemp(join(tmpdir(), "pi-sandbox-external-"));
|
|
48
54
|
await chmod(directory, 0o700);
|
|
49
55
|
const socketPath = join(directory, "supervisor.sock");
|
|
@@ -101,7 +107,14 @@ export async function createExternalWorkerSupervisor(
|
|
|
101
107
|
startedAt: Date.now(),
|
|
102
108
|
requests: 0,
|
|
103
109
|
});
|
|
104
|
-
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`);
|
|
105
118
|
lifecycle.registered?.({
|
|
106
119
|
id: request.workerId as string,
|
|
107
120
|
cwd: request.cwd as string,
|
|
@@ -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";
|
|
@@ -171,6 +172,7 @@ function sandboxOperations(
|
|
|
171
172
|
ctx: ExtensionContext,
|
|
172
173
|
turnIndex: () => number,
|
|
173
174
|
additionalAllowRead: readonly string[],
|
|
175
|
+
network: NetworkConfig,
|
|
174
176
|
hostIPC: HostIPCConfig,
|
|
175
177
|
sandbox?: PiSandboxExtensionOptions["sandbox"],
|
|
176
178
|
): BashOperations {
|
|
@@ -222,7 +224,7 @@ function sandboxOperations(
|
|
|
222
224
|
onData: options.onData,
|
|
223
225
|
onStderr,
|
|
224
226
|
shellPath,
|
|
225
|
-
policy: createDefaultPolicy(cwd, { additionalAllowRead }),
|
|
227
|
+
policy: createDefaultPolicy(cwd, { additionalAllowRead, network }),
|
|
226
228
|
review: async (trap) => {
|
|
227
229
|
const result = await approveSandboxTrap(trap, approvalContext);
|
|
228
230
|
if (result.action === "deny" && result.reason) {
|
|
@@ -265,6 +267,7 @@ async function performRegistration(
|
|
|
265
267
|
externalWorkerIsolation,
|
|
266
268
|
);
|
|
267
269
|
const additionalAllowRead = config.filesystem.additionalAllowRead;
|
|
270
|
+
const network = config.network;
|
|
268
271
|
const hostIPC = options.hostIPC ?? config.hostIPC;
|
|
269
272
|
const subagents =
|
|
270
273
|
subagentProvider === "builtin"
|
|
@@ -292,6 +295,7 @@ async function performRegistration(
|
|
|
292
295
|
ctx,
|
|
293
296
|
() => currentTurn,
|
|
294
297
|
additionalAllowRead,
|
|
298
|
+
network,
|
|
295
299
|
hostIPC,
|
|
296
300
|
options.sandbox,
|
|
297
301
|
),
|
|
@@ -380,7 +384,7 @@ async function performRegistration(
|
|
|
380
384
|
cwd: ctx.cwd,
|
|
381
385
|
model: params.model,
|
|
382
386
|
tools: pi.getActiveTools().filter((name) => name !== "subagent"),
|
|
383
|
-
policy: createDefaultPolicy(ctx.cwd, { additionalAllowRead }),
|
|
387
|
+
policy: createDefaultPolicy(ctx.cwd, { additionalAllowRead, network }),
|
|
384
388
|
sandbox: options.sandbox,
|
|
385
389
|
review: async (trap: Parameters<typeof approveSandboxTrap>[0]) =>
|
|
386
390
|
(await approveSandboxTrap(trap, approvalContext)).action,
|
|
@@ -546,6 +550,7 @@ async function performRegistration(
|
|
|
546
550
|
ctx,
|
|
547
551
|
() => currentTurn,
|
|
548
552
|
additionalAllowRead,
|
|
553
|
+
network,
|
|
549
554
|
hostIPC,
|
|
550
555
|
options.sandbox,
|
|
551
556
|
),
|
|
@@ -606,6 +611,7 @@ async function performRegistration(
|
|
|
606
611
|
registered: (worker) => fleetView?.registered(worker),
|
|
607
612
|
unregistered: (worker) => fleetView?.unregistered(worker.id),
|
|
608
613
|
},
|
|
614
|
+
network,
|
|
609
615
|
);
|
|
610
616
|
isolation = enableExternalWorkerIsolation(supervisor);
|
|
611
617
|
externalIsolationFailure = undefined;
|
|
@@ -657,8 +663,8 @@ async function performRegistration(
|
|
|
657
663
|
if (ctx.hasUI) {
|
|
658
664
|
ctx.ui.notify(
|
|
659
665
|
process.platform === "darwin"
|
|
660
|
-
? `${EXTENSION_NAME} enabled: macOS Sandbox Runtime with static filesystem policy and per-connection network review`
|
|
661
|
-
: `${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`,
|
|
662
668
|
"info",
|
|
663
669
|
);
|
|
664
670
|
}
|
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: [],
|